| 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 |