返回 DeepSeek-Reasonix
resume_test.go
根目录 / internal / cli / resume_test.go
1 package cli
2
3 import (
4 "os"
5 "path/filepath"
6 "strconv"
7 "strings"
8 "testing"
9 "time"
10
11 tea "charm.land/bubbletea/v2"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/config"
15 "reasonix/internal/control"
16 "reasonix/internal/event"
17 "reasonix/internal/provider"
18 )
19
20 // TestResumeDispatchOpensPicker proves bare "/resume" opens the interactive
21 // picker without duplicating the same list in transcript scrollback.
22 func TestResumeDispatchOpensPicker(t *testing.T) {
23 dir := t.TempDir()
24 saveTestSession(t, filepath.Join(dir, "a.jsonl"), "alpha prompt")
25 saveTestSession(t, filepath.Join(dir, "b.jsonl"), "beta prompt")
26
27 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
28 m := newTestChatTUI()
29 m.width = 80
30 m.ctrl = newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
31
32 if cmd := m.runSlashCommand("/resume"); cmd != nil {
33 t.Fatal("/resume should not return a tea.Cmd")
34 }
35 if m.resumePick == nil {
36 t.Fatal("bare /resume should open the picker")
37 }
38 if len(m.resumePick.entries) != 2 {
39 t.Fatalf("picker should have 2 sessions, got %d", len(m.resumePick.entries))
40 }
41 out := strings.Join(m.transcript, "\n")
42 if strings.Contains(out, "alpha prompt") || strings.Contains(out, "beta prompt") {
43 t.Fatalf("picker previews should not be duplicated in scrollback:\n%s", out)
44 }
45 }
46
47 func TestOrderResumeSessionsGroupsRecoveryCopiesAndPrefersNewestLeaf(t *testing.T) {
48 base := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
49 root := agent.SessionInfo{Path: "/sessions/root.jsonl", ModTime: base.Add(4 * time.Minute)}
50 olderLeaf := agent.SessionInfo{
51 Path: "/sessions/recovery-old.jsonl", ModTime: base.Add(2 * time.Minute),
52 Recovered: true, ParentID: "root",
53 }
54 newerLeaf := agent.SessionInfo{
55 Path: "/sessions/recovery-new.jsonl", ModTime: base.Add(3 * time.Minute),
56 Recovered: true, ParentID: "root",
57 }
58 other := agent.SessionInfo{Path: "/sessions/other.jsonl", ModTime: base.Add(time.Minute)}
59
60 got := orderResumeSessions([]agent.SessionInfo{root, newerLeaf, olderLeaf, other})
61 want := []string{newerLeaf.Path, olderLeaf.Path, root.Path, other.Path}
62 if len(got) != len(want) {
63 t.Fatalf("ordered sessions len = %d, want %d", len(got), len(want))
64 }
65 for i := range want {
66 if got[i].Path != want[i] {
67 t.Fatalf("ordered[%d] = %q, want %q (all=%v)", i, got[i].Path, want[i], got)
68 }
69 }
70 }
71
72 func TestNewestResumeTargetIgnoresRecoveryPickerLeafPreference(t *testing.T) {
73 dir := t.TempDir()
74 rootPath := filepath.Join(dir, "root.jsonl")
75 recoveryPath := filepath.Join(dir, "recovery.jsonl")
76 saveTestSession(t, rootPath, "latest parent prompt")
77 saveTestSession(t, recoveryPath, "older recovery prompt")
78
79 base := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
80 if err := agent.SaveBranchMetaPreserveUpdated(rootPath, agent.BranchMeta{
81 ID: "root", CreatedAt: base, UpdatedAt: base.Add(4 * time.Minute),
82 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1, Preview: "latest parent prompt",
83 }); err != nil {
84 t.Fatalf("save root meta: %v", err)
85 }
86 if err := agent.SaveBranchMetaPreserveUpdated(recoveryPath, agent.BranchMeta{
87 ID: "recovery", ParentID: "root", Recovered: true,
88 CreatedAt: base, UpdatedAt: base.Add(3 * time.Minute),
89 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1, Preview: "older recovery prompt",
90 }); err != nil {
91 t.Fatalf("save recovery meta: %v", err)
92 }
93
94 grouped := mergedResumeSessions(dir)
95 if len(grouped) != 2 || grouped[0].Path != recoveryPath {
96 t.Fatalf("interactive resume order = %+v, want recovery leaf grouped first", grouped)
97 }
98 latest, ok := newestResumeTarget(dir)
99 if !ok {
100 t.Fatal("newestResumeTarget found no session")
101 }
102 if latest.path != rootPath {
103 t.Fatalf("--continue session = %q, want chronologically newest %q", latest.path, rootPath)
104 }
105 }
106
107 func TestRunResumeKeepsCompletedIndexStableAcrossRecoveryGC(t *testing.T) {
108 dir := t.TempDir()
109 parentPath := filepath.Join(dir, "recovery-parent.jsonl")
110 disk := agent.NewSession("sys")
111 disk.Add(provider.Message{Role: provider.RoleUser, Content: "shared prompt"})
112 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk answer"})
113 if err := disk.Save(parentPath); err != nil {
114 t.Fatalf("save parent: %v", err)
115 }
116 stale := agent.NewSession("sys")
117 stale.Add(provider.Message{Role: provider.RoleUser, Content: "shared prompt"})
118 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "recovered answer"})
119 recovery, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
120 if err != nil {
121 t.Fatalf("save recovery branch: %v", err)
122 }
123 covered, err := agent.LoadSession(parentPath)
124 if err != nil {
125 t.Fatalf("load recovery parent: %v", err)
126 }
127 covered.Replace(append([]provider.Message(nil), stale.Snapshot()...))
128 covered.Add(provider.Message{Role: provider.RoleUser, Content: "later parent turn"})
129 if err := covered.SaveRewrite(parentPath); err != nil {
130 t.Fatalf("cover recovery branch in parent: %v", err)
131 }
132 recoveryMeta, ok, err := agent.LoadBranchMeta(recovery.Path)
133 if err != nil || !ok {
134 t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
135 }
136 recoveryMeta.UpdatedAt = time.Now().Add(-2 * agent.RecoveryGCGracePeriod)
137 if err := agent.SaveBranchMetaPreserveUpdated(recovery.Path, recoveryMeta); err != nil {
138 t.Fatalf("age recovery branch: %v", err)
139 }
140
141 targetPath := filepath.Join(dir, "wanted.jsonl")
142 saveTestSession(t, targetPath, "WANTED-SESSION")
143 targetMeta, ok, err := agent.LoadBranchMeta(targetPath)
144 if err != nil || !ok {
145 t.Fatalf("load target meta: ok=%v err=%v", ok, err)
146 }
147 targetMeta.UpdatedAt = time.Now().Add(-4 * agent.RecoveryGCGracePeriod)
148 if err := agent.SaveBranchMetaPreserveUpdated(targetPath, targetMeta); err != nil {
149 t.Fatalf("age target session: %v", err)
150 }
151
152 candidates, err := agent.ReclaimableRecoveryBranches(dir, time.Now(), agent.RecoveryGCGracePeriod)
153 if err != nil || len(candidates) != 1 || candidates[0] != recovery.Path {
154 t.Fatalf("recovery GC precondition = %v err=%v, want %q", candidates, err, recovery.Path)
155 }
156
157 active := agent.NewSession("sys")
158 active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
159 exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
160 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
161 activePath := filepath.Join(dir, "active-unpersisted.jsonl")
162 ctrl.SetSessionPath(activePath)
163 m := newTestChatTUI()
164 m.width = 80
165 m.ctrl = ctrl
166 // The user reads the index off a list rendered while the controller is
167 // alive, so compute it the same way once the never-snapshotted session's
168 // catalog metadata settles and both listings agree on the rows.
169 waitForCatalogMetadata(t, dir, agent.BranchID(activePath))
170 sessions := mergedResumeSessions(dir)
171 targetIndex := 0
172 for i, session := range sessions {
173 if session.Path == targetPath {
174 targetIndex = i + 1
175 }
176 }
177 if targetIndex != len(sessions) || targetIndex < 2 {
178 t.Fatalf("target index = %d in %+v, want a trailing row shifted by GC", targetIndex, sessions)
179 }
180
181 m.runResumeCommand("/resume " + strconv.Itoa(targetIndex))
182
183 if got := ctrl.SessionPath(); got != targetPath {
184 t.Fatalf("session path = %q, want completed index target %q", got, targetPath)
185 }
186 if _, err := os.Stat(recovery.Path); err != nil {
187 t.Fatalf("numeric resume mutated its displayed session list: %v", err)
188 }
189 }
190
191 func TestCapResumeSessionGroupsDoesNotSplitRecoveryFamily(t *testing.T) {
192 base := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
193 sessions := make([]agent.SessionInfo, 0, 12)
194 for i := range 9 {
195 sessions = append(sessions, agent.SessionInfo{
196 Path: filepath.Join("/sessions", "standalone-"+strconv.Itoa(i)+".jsonl"),
197 ModTime: base.Add(time.Duration(20-i) * time.Minute),
198 })
199 }
200 sessions = append(sessions,
201 agent.SessionInfo{Path: "/sessions/root.jsonl", ModTime: base.Add(3 * time.Minute)},
202 agent.SessionInfo{Path: "/sessions/recovery-a.jsonl", ModTime: base.Add(2 * time.Minute), Recovered: true, ParentID: "root"},
203 agent.SessionInfo{Path: "/sessions/recovery-b.jsonl", ModTime: base.Add(time.Minute), Recovered: true, ParentID: "root"},
204 )
205
206 entries := make([]resumeEntry, 0, len(sessions))
207 for _, session := range orderResumeSessions(sessions) {
208 entries = append(entries, resumeEntry{session: session, target: cliResumeTarget{path: session.Path}})
209 }
210 got := capResumeEntries(entries, resumeListCap)
211 if len(got) != 9 {
212 t.Fatalf("capped sessions len = %d, want 9 complete standalone groups", len(got))
213 }
214 for _, entry := range got {
215 if entry.session.Recovered || agent.BranchID(entry.session.Path) == "root" {
216 t.Fatalf("cap split recovery family instead of omitting it: %+v", got)
217 }
218 }
219 }
220
221 func TestSessionPickerLabelIdentifiesRecoveryParent(t *testing.T) {
222 session := agent.SessionInfo{
223 Path: "/sessions/recovery.jsonl", Preview: "keep working", Turns: 3,
224 Recovered: true, ParentID: "20260803-long-parent-id",
225 }
226 label := sessionPickerLabel(session)
227 if recoverySessionBadge(session) == "" || !strings.Contains(label, "20260803") {
228 t.Fatalf("recovery picker label = %q, want recovery badge and short parent id", label)
229 }
230 }
231
232 // TestResumePickerNavigateAndSelect proves the picker's up/down navigation and
233 // Enter to resume the selected session.
234 func TestResumePickerNavigateAndSelect(t *testing.T) {
235 dir := t.TempDir()
236 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
237 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
238
239 // Create two saved sessions.
240 aPath := filepath.Join(dir, "a.jsonl")
241 saveTestSession(t, aPath, "first session prompt")
242 bPath := filepath.Join(dir, "b.jsonl")
243 saveTestSession(t, bPath, "SECOND-SESSION-PROMPT")
244 // Pin distinct mtimes so b is unambiguously the most recent. Created back to
245 // back, the two files can land in the same filesystem mtime tick (seen on the
246 // CI Windows runner), which then tie-breaks to a.jsonl by path and flakes.
247 now := time.Now()
248 if err := os.Chtimes(aPath, now.Add(-2*time.Second), now.Add(-2*time.Second)); err != nil {
249 t.Fatal(err)
250 }
251 if err := os.Chtimes(bPath, now, now); err != nil {
252 t.Fatal(err)
253 }
254
255 m := newTestChatTUI()
256 m.width = 80
257 m.ctrl = ctrl
258
259 // Open the picker via bare /resume.
260 m.runSlashCommand("/resume")
261 if m.resumePick == nil {
262 t.Fatal("bare /resume should open the picker")
263 }
264 if len(m.resumePick.entries) != 2 {
265 t.Fatalf("picker should have 2 sessions, got %d", len(m.resumePick.entries))
266 }
267
268 // The first session (default selection) is the most recent, which is b.jsonl.
269 // Press Enter to resume it.
270 next, _ := m.handleResumePickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
271 m = next.(chatTUI)
272
273 if got := ctrl.SessionPath(); got != bPath {
274 t.Fatalf("session path = %q, want %q", got, bPath)
275 }
276 if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "SECOND-SESSION-PROMPT") {
277 t.Fatalf("transcript should replay the resumed session:\n%s", out)
278 }
279 if m.resumePick != nil {
280 t.Fatal("picker should close after resume")
281 }
282 }
283
284 // TestResumePickerEscDismisses proves pressing Esc closes the picker without
285 // switching sessions.
286 func TestResumePickerEscDismisses(t *testing.T) {
287 dir := t.TempDir()
288 saveTestSession(t, filepath.Join(dir, "a.jsonl"), "alpha prompt")
289
290 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
291 m := newTestChatTUI()
292 m.ctrl = newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
293
294 m.runSlashCommand("/resume")
295 if m.resumePick == nil {
296 t.Fatal("bare /resume should open the picker")
297 }
298
299 next, _ := m.handleResumePickerKey(tea.KeyPressMsg{Code: tea.KeyEsc})
300 m = next.(chatTUI)
301 if m.resumePick != nil {
302 t.Fatal("picker should close on Esc")
303 }
304 }
305
306 // TestResumeDispatchSwitchesAndReplays drives "/resume <n>" through the slash
307 // dispatcher and asserts the controller switched session AND the resumed
308 // transcript was replayed into the scrollback.
309 func TestResumeDispatchSwitchesAndReplays(t *testing.T) {
310 dir := t.TempDir()
311 active := agent.NewSession("sys")
312 active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
313 exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
314 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
315 ctrl.SetSessionPath(filepath.Join(dir, "active.jsonl"))
316 if err := ctrl.Snapshot(); err != nil {
317 t.Fatal(err)
318 }
319
320 otherPath := filepath.Join(dir, "other.jsonl")
321 saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")
322
323 m := newTestChatTUI()
324 m.width = 80
325 m.ctrl = ctrl
326
327 target := 0
328 for i, s := range mergedResumeSessions(dir) {
329 if s.Path == otherPath {
330 target = i + 1
331 }
332 }
333 if target == 0 {
334 t.Fatal("other session not listed by recentSessions")
335 }
336
337 m.runSlashCommand("/resume " + strconv.Itoa(target))
338
339 if got := ctrl.SessionPath(); got != otherPath {
340 t.Fatalf("session path = %q, want %q", got, otherPath)
341 }
342 if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "OTHER-SESSION-PROMPT") {
343 t.Fatalf("transcript should replay the resumed session:\n%s", out)
344 }
345 }
346
347 // TestResumeWhileScrolledUpPinsViewportToBottom covers the session-switch
348 // regression where a stale scroll offset was preserved if the user had read
349 // back in the old transcript before resuming another session.
350 func TestResumeWhileScrolledUpPinsViewportToBottom(t *testing.T) {
351 dir := t.TempDir()
352 active := agent.NewSession("sys")
353 for i := range 18 {
354 active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt " + strconv.Itoa(i)})
355 }
356 exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
357 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
358 activePath := filepath.Join(dir, "active.jsonl")
359 ctrl.SetSessionPath(activePath)
360 if err := ctrl.Snapshot(); err != nil {
361 t.Fatal(err)
362 }
363
364 otherPath := filepath.Join(dir, "other.jsonl")
365 saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")
366
367 target := 0
368 for i, s := range mergedResumeSessions(dir) {
369 if s.Path == otherPath {
370 target = i + 1
371 }
372 }
373 if target == 0 {
374 t.Fatal("other session not listed by recentSessions")
375 }
376
377 adv := func(m chatTUI, msg tea.Msg) chatTUI {
378 n, _ := m.Update(msg)
379 return n.(chatTUI)
380 }
381
382 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 8})
383 if !cur.viewport.AtBottom() {
384 t.Fatal("initial resumed history should start at the bottom")
385 }
386
387 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
388 if cur.viewport.AtBottom() {
389 t.Fatal("wheel-up should move the old transcript away from the bottom")
390 }
391
392 cur.input.SetValue("/resume " + strconv.Itoa(target))
393 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
394
395 if got := ctrl.SessionPath(); got != otherPath {
396 t.Fatalf("session path = %q, want %q", got, otherPath)
397 }
398 out := strings.Join(cur.transcript, "\n")
399 if !strings.Contains(out, "OTHER-SESSION-PROMPT") {
400 t.Fatalf("transcript should replay the resumed session:\n%s", out)
401 }
402 if strings.Contains(out, "active prompt") {
403 t.Fatalf("transcript should not retain the previous session after resume:\n%s", out)
404 }
405 if !cur.viewport.AtBottom() {
406 t.Fatalf("resume while scrolled up should pin to bottom, AtBottom=%v, YOffset=%d", cur.viewport.AtBottom(), cur.viewport.YOffset())
407 }
408 }
409
410 // mergedResumeSessions projects the merged picker rows onto the legacy row
411 // shape the older listing tests assert against.
412 func mergedResumeSessions(dir string) []agent.SessionInfo {
413 entries := mergedResumeEntries(dir, resumeListCap)
414 out := make([]agent.SessionInfo, 0, len(entries))
415 for _, entry := range entries {
416 out = append(out, entry.session)
417 }
418 return out
419 }
420
421 func saveTestSession(t *testing.T, path, prompt string) {
422 t.Helper()
423 s := agent.NewSession("sys")
424 s.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
425 if err := s.Save(path); err != nil {
426 t.Fatal(err)
427 }
428 }
429
430 // TestResumeArgCompletionListsSessions proves "/resume " opens an indexed menu
431 // of the saved sessions, mirroring the /switch branch completion.
432 func TestResumeArgCompletionListsSessions(t *testing.T) {
433 dir := t.TempDir()
434 saveTestSession(t, filepath.Join(dir, "a.jsonl"), "first")
435 saveTestSession(t, filepath.Join(dir, "b.jsonl"), "second")
436
437 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
438 m := newTestChatTUI()
439 m.ctrl = newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
440
441 m.input.SetValue("/resume ")
442 m.updateCompletion()
443 if !m.completion.active || m.completion.kind != compSlashArg {
444 t.Fatalf("/resume should open argument completion: %+v", m.completion)
445 }
446 if got := labels(m.completion.items); len(got) != 2 || got[0] != "1" || got[1] != "2" {
447 t.Fatalf("resume completion = %v, want [1 2]", got)
448 }
449 }
450
451 // TestResumeAcceptChainsIntoSessionMenu proves accepting "/resume" (a
452 // non-descend command that still takes arguments) immediately opens the session
453 // menu, rather than waiting for the next keystroke.
454 func TestResumeAcceptChainsIntoSessionMenu(t *testing.T) {
455 dir := t.TempDir()
456 saveTestSession(t, filepath.Join(dir, "a.jsonl"), "first")
457
458 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
459 m := newTestChatTUI()
460 m.ctrl = newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
461
462 m.input.SetValue("/resu")
463 m.updateCompletion()
464 m.acceptCompletion()
465 if got := m.input.Value(); got != "/resume " {
466 t.Fatalf("accepting /resume should fill %q, got %q", "/resume ", got)
467 }
468 if !m.completion.active || m.completion.kind != compSlashArg {
469 t.Fatalf("accepting /resume should chain into the session menu: %+v", m.completion)
470 }
471 }
472
473 // TestRunResumeSwitchesSession proves "/resume <n>" repoints the running
474 // controller to the chosen saved session and loads its history.
475 func TestRunResumeSwitchesSession(t *testing.T) {
476 dir := t.TempDir()
477
478 active := agent.NewSession("sys")
479 active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
480 exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
481 ctrl := newOwnedTestController(t, control.Options{Executor: exec, SessionDir: dir, Label: "test"})
482 activePath := filepath.Join(dir, "active.jsonl")
483 ctrl.SetSessionPath(activePath)
484 if err := ctrl.Snapshot(); err != nil {
485 t.Fatal(err)
486 }
487
488 otherPath := filepath.Join(dir, "other.jsonl")
489 saveTestSession(t, otherPath, "other prompt")
490
491 m := newTestChatTUI()
492 m.width = 80
493 m.ctrl = ctrl
494
495 target := 0
496 for i, s := range mergedResumeSessions(dir) {
497 if s.Path == otherPath {
498 target = i + 1
499 }
500 }
501 if target == 0 {
502 t.Fatal("saved session not listed by recentSessions")
503 }
504
505 m.runResumeCommand("/resume " + strconv.Itoa(target))
506
507 if got := ctrl.SessionPath(); got != otherPath {
508 t.Fatalf("session path = %q, want %q", got, otherPath)
509 }
510 hist := ctrl.History()
511 if len(hist) == 0 || hist[len(hist)-1].Content != "other prompt" {
512 t.Fatalf("history not loaded from target: %+v", hist)
513 }
514 }
515
516 // TestResumeEntriesIncludeOtherProjects proves the picker surfaces the newest
517 // session of other known projects (#9477): a user who worked here over SSH
518 // resumes from any directory, not only the original workspace root.
519 func TestResumeEntriesIncludeOtherProjects(t *testing.T) {
520 currentDir := t.TempDir()
521 current := filepath.Join(currentDir, "current.jsonl")
522 saveResumeTestSession(t, current, "current project work")
523
524 otherRoot := t.TempDir()
525 otherDir := config.ProjectSessionDir(otherRoot)
526 if otherDir == "" {
527 t.Skip("project session dir unavailable")
528 }
529 if err := os.MkdirAll(otherDir, 0o755); err != nil {
530 t.Fatal(err)
531 }
532 if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-projects.json"),
533 []byte(`{"projects":[{"root":`+strconv.Quote(filepath.ToSlash(otherRoot))+`}]}`), 0o644); err != nil {
534 t.Fatal(err)
535 }
536 other := filepath.Join(otherDir, "other.jsonl")
537 saveResumeTestSession(t, other, "other project work")
538
539 entries := resumeEntries(currentDir)
540 if len(entries) != 2 {
541 t.Fatalf("resumeEntries = %d entries, want current + other project", len(entries))
542 }
543 if entries[0].project != "" || entries[0].session.Path != current {
544 t.Fatalf("first entry = %+v, want the current directory session", entries[0])
545 }
546 if entries[1].project == "" {
547 t.Fatalf("second entry = %+v, want a project label for the other project", entries[1])
548 }
549 if entries[1].session.Path != other {
550 t.Fatalf("second entry path = %q, want %q", entries[1].session.Path, other)
551 }
552 }
553
554 func saveResumeTestSession(t *testing.T, path, content string) {
555 t.Helper()
556 s := agent.NewSession("sys")
557 s.Add(provider.Message{Role: provider.RoleUser, Content: content})
558 if err := s.Save(path); err != nil {
559 t.Fatal(err)
560 }
561 }
562
562 lines GO