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