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