package modes import ( "context" "os" "path/filepath" "strings" "sync" "testing" "time" "unicode" "github.com/bnema/zut/packages/core" "github.com/bnema/zut/packages/provider" "github.com/bnema/zut/packages/tui" ) const sessionDialogTestTimeout = 10 * time.Second func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) { row := formatSessionRowPlain(core.SessionSummary{ Provider: "test\x1b]0;bad\a", Model: "model\x1b[31m", MessageCount: 1, FirstUserText: "hello\x1b[2J\nworld", }, 120) if strings.IndexFunc(row, unicode.IsControl) >= 0 { t.Fatalf("session row contains control characters: %q", row) } if !strings.Contains(row, "hello world") { t.Fatalf("sanitized session text missing: %q", row) } } func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { root := t.TempDir() cwd := t.TempDir() session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") if err != nil { t.Fatal(err) } if err := session.AppendMessage(provider.Message{ Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "load this session"}}, }); err != nil { t.Fatal(err) } if err := session.Close(); err != nil { t.Fatal(err) } d := newSessionDialog() events := d.Open(context.Background(), root, cwd) t.Cleanup(d.Close) if !d.Active() || !d.Loading() { t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) } loadingText := strings.Join(d.Render(tui.Dark, 100), "\n") if !strings.Contains(loadingText, "Loading sessions") { t.Fatalf("loading render = %q, want spinner status", loadingText) } act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if act.Select || !d.Active() { t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active()) } for event := range events { d.ApplyLoad(event) } if d.Loading() { t.Fatal("dialog still loading after final result") } if len(d.sessions) != 1 || d.sessions[0].Path != session.Path { t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path) } loadedText := strings.Join(d.Render(tui.Dark, 100), "\n") if strings.Contains(loadedText, "Loading sessions") { t.Fatalf("completed render still shows loading spinner: %q", loadedText) } if !strings.Contains(loadedText, "load this session") { t.Fatalf("completed render missing session text: %q", loadedText) } } func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() d := newSessionDialog() events := d.Open(ctx, t.TempDir(), t.TempDir()) if d.Loading() { t.Fatal("dialog remained loading with an already-canceled parent") } if d.Active() { t.Fatal("dialog remained active with an already-canceled parent") } if _, ok := <-events; ok { t.Fatal("canceled dialog emitted a load event") } } func TestSessionDialogCancellationFinishesLoading(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) d := newSessionDialog() events := d.Open(ctx, t.TempDir(), t.TempDir()) cancel() for range events { } d.ApplyLoadClosed() if d.Loading() { t.Fatal("dialog remained loading after parent cancellation") } if d.Active() { t.Fatal("dialog remained active after parent cancellation") } } func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) { root := t.TempDir() cwd := t.TempDir() session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") if err != nil { t.Fatal(err) } if err := session.AppendMessage(provider.Message{ Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}}, }); err != nil { t.Fatal(err) } if err := session.Close(); err != nil { t.Fatal(err) } entered := make(chan struct{}) d := newSessionDialog() d.describeSession = func(ctx context.Context, path string) core.SessionSummary { close(entered) <-ctx.Done() return core.SessionSummary{Path: path} } ctx, cancel := context.WithCancel(context.Background()) events := d.Open(ctx, root, cwd) t.Cleanup(func() { cancel() d.Close() }) select { case event, ok := <-events: if !ok || event.kind != sessionLoadStarted { t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok) } d.ApplyLoad(event) case <-time.After(sessionDialogTestTimeout): t.Fatal("timed out waiting for sessionLoadStarted") } select { case <-entered: case <-time.After(sessionDialogTestTimeout): t.Fatal("timed out waiting for the session worker") } cancel() for { select { case event, ok := <-events: if !ok { d.ApplyLoadClosed() if d.Loading() || d.Active() { t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active()) } return } if event.kind == sessionLoadFinished { t.Fatal("canceled load emitted sessionLoadFinished") } d.ApplyLoad(event) case <-time.After(sessionDialogTestTimeout): t.Fatal("timed out waiting for canceled session load to close") } } } func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) { root := t.TempDir() cwd := t.TempDir() session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") if err != nil { t.Fatal(err) } if err := session.AppendMessage(provider.Message{ Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}}, }); err != nil { t.Fatal(err) } if err := session.Close(); err != nil { t.Fatal(err) } entered := make(chan struct{}) release := make(chan struct{}) var enteredOnce sync.Once var releaseOnce sync.Once releaseLoad := func() { releaseOnce.Do(func() { close(release) }) } d := newSessionDialog() d.describeSession = func(ctx context.Context, path string) core.SessionSummary { enteredOnce.Do(func() { close(entered) }) <-release return core.SessionSummary{Path: path, MessageCount: 1} } firstCtx, firstCancel := context.WithCancel(context.Background()) firstEvents := d.Open(firstCtx, root, cwd) t.Cleanup(func() { firstCancel() releaseLoad() d.Close() }) select { case event, ok := <-firstEvents: if !ok || event.kind != sessionLoadStarted { t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok) } d.ApplyLoad(event) case <-time.After(sessionDialogTestTimeout): t.Fatal("timed out waiting for the first load to start") } select { case <-entered: case <-time.After(sessionDialogTestTimeout): t.Fatal("timed out waiting for the first worker") } preCanceled, cancelPreCanceled := context.WithCancel(context.Background()) cancelPreCanceled() preCanceledEvents := d.Open(preCanceled, root, cwd) if _, ok := <-preCanceledEvents; ok { t.Fatal("pre-canceled open emitted a load event") } thirdCtx, cancelThird := context.WithCancel(context.Background()) thirdEvents := d.Open(thirdCtx, root, cwd) select { case event, ok := <-thirdEvents: t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok) case <-time.After(100 * time.Millisecond): } releaseLoad() firstCancel() select { case event, ok := <-thirdEvents: if !ok || event.kind != sessionLoadStarted { t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok) } d.ApplyLoad(event) case <-time.After(sessionDialogTestTimeout): t.Fatal("timed out waiting for third load after releasing the first") } cancelThird() for event := range thirdEvents { d.ApplyLoad(event) } } func TestSessionDialogEscapeCancelsLoading(t *testing.T) { d := newSessionDialog() events := d.Open(context.Background(), t.TempDir(), t.TempDir()) act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if !act.Close || d.Active() || d.Loading() { t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading()) } for range events { } } func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) { d := newSessionDialog() d.active = true d.loading = true d.loadGeneration = 7 d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadStarted, generation: 7, total: 4, }) d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadEntry, generation: 7, index: 2, summary: core.SessionSummary{Path: "third", MessageCount: 1}, }) if len(d.sessions) != 0 { t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions) } d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadEntry, generation: 7, index: 1, summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true}, }) d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadEntry, generation: 7, index: 3, summary: core.SessionSummary{Path: "empty"}, }) d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadEntry, generation: 7, index: 0, summary: core.SessionSummary{Path: "first", MessageCount: 1}, }) if !d.Loading() { t.Fatal("dialog stopped loading before the terminal event") } if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" { t.Fatalf("incremental sessions = %+v, want first then third", d.sessions) } d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7}) if d.Loading() { t.Fatal("dialog still loading after final result") } if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" { t.Fatalf("loaded sessions = %+v, want first then third", d.sessions) } } func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) { d := newSessionDialog() d.active = true d.loading = true d.loadGeneration = 9 d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadStarted, generation: 9, total: 2, }) d.ApplyLoad(sessionLoadEvent{ kind: sessionLoadEntry, generation: 9, index: 0, summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"}, }) if !d.Loading() { t.Fatal("dialog stopped loading after first entry") } rendered := strings.Join(d.Render(tui.Dark, 100), "\n") if !strings.Contains(rendered, "recent work") { t.Fatalf("incremental render missing newest entry: %q", rendered) } act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !act.Select || act.Path != "newest" || d.Active() { t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active()) } } func TestSessionDialogAllScopeIncludesOtherCWDBuckets(t *testing.T) { root := t.TempDir() cwdA := t.TempDir() cwdB := t.TempDir() for _, cwd := range []string{cwdA, cwdB} { session, err := core.NewSession(root, cwd, "test", "model", "test") if err != nil { t.Fatal(err) } if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "scope"}}}); err != nil { t.Fatal(err) } if err := session.Close(); err != nil { t.Fatal(err) } } d := newSessionDialog() events := d.Open(context.Background(), root, cwdA, true) defer d.Close() for event := range events { d.ApplyLoad(event) } if !d.allScope || len(d.sessions) != 2 { t.Fatalf("all scope = %v, sessions = %#v", d.allScope, d.sessions) } if action := d.HandleKey(tui.Key{Kind: tui.KeyTab}); !action.ToggleScope { t.Fatalf("tab action = %#v, want scope toggle", action) } } func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) { d := newSessionDialog() d.active = true d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1}, {Path: "two", MessageCount: 1}} d.sessions = append([]core.SessionSummary(nil), d.baseSessions...) d.query = "alpha" d.searchReady = true d.searchGeneration = 1 d.searchMatches = matchSessionSearchSegments(context.Background(), d.query, []core.SessionSearchSegment{ {Path: "one", Text: "alpha implementation details"}, {Path: "one", Text: "alpha follow-up"}, {Path: "two", Text: "unrelated text"}, }) d.applySearchFilter() if len(d.sessions) != 1 || d.sessions[0].Path != "one" { t.Fatalf("search sessions = %#v", d.sessions) } match := d.searchMatches["one"] if match.count != 2 || match.excerpt == "" { t.Fatalf("search match = %#v", match) } rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n")) if !strings.Contains(rendered, "2 matches") || !strings.Contains(rendered, "alpha") { t.Fatalf("search render = %q", rendered) } } func TestSessionDialogSearchShowsAnimatedLoadingFeedback(t *testing.T) { d := newSessionDialog() d.active = true d.loading = true // Keep StartSearch pending; no background read is needed. d.query = "go" if events := d.StartSearch(context.Background()); events == nil { t.Fatal("StartSearch returned nil") } defer d.Close() if !d.SearchLoading() { t.Fatal("search is not reported as loading while indexing") } if d.searchSpinner == nil { t.Fatal("search did not initialize the shared spinner") } indexed := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) if !strings.Contains(indexed, "Indexing session text") { t.Fatalf("indexing render = %q", indexed) } d.searching = false d.matching = true matching := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n")) if !strings.Contains(matching, "Searching session text") { t.Fatalf("matching render = %q", matching) } d.matching = false if d.SearchLoading() { t.Fatal("search remains reported as loading after matching completes") } } func TestSessionDialogSearchIgnoresStaleMatchGeneration(t *testing.T) { d := newSessionDialog() d.active = true d.query = "abc" d.searchGeneration = 1 d.matchGeneration = 2 d.matching = true d.ApplySearch(sessionSearchEvent{ kind: sessionSearchMatchesReady, generation: 1, matchGeneration: 1, query: "abc", matches: map[string]sessionSearchMatch{"stale": {count: 1}}, }) if !d.matching { t.Fatal("stale match result cleared the active search state") } if d.searchMatches != nil { t.Fatalf("stale match result was applied: %#v", d.searchMatches) } d.ApplySearch(sessionSearchEvent{ kind: sessionSearchMatchesReady, generation: 1, matchGeneration: 2, query: "abc", matches: map[string]sessionSearchMatch{"current": {count: 1}}, }) if d.matching { t.Fatal("current match result did not finish the active search") } if _, ok := d.searchMatches["current"]; !ok { t.Fatalf("current match result was not applied: %#v", d.searchMatches) } } func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) { segments := []core.SessionSearchSegment{ {Path: "scattered", Text: "sizzle then harp, again remote"}, {Path: "exact", Text: "resume the sharm project"}, } if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil { t.Fatalf("one-character search = %#v, want no search", matches) } matches := matchSessionSearchSegments(context.Background(), "sharm", segments) if len(matches) != 1 { t.Fatalf("matches = %#v, want only exact session", matches) } match, ok := matches["exact"] if !ok { t.Fatalf("matches = %#v, missing exact session", matches) } if match.count != 1 || match.excerpt != "resume the sharm project" { t.Fatalf("exact match = %#v", match) } if want := []int{11, 12, 13, 14, 15}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] { t.Fatalf("highlight indexes = %v, want %v", match.indexes, want) } } func TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) { matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{ {Path: "repeated", Text: "sharm then sharm"}, }) match, ok := matches["repeated"] if !ok { t.Fatalf("matches = %#v, missing repeated session", matches) } if match.count != 2 { t.Fatalf("match count = %d, want 2", match.count) } if match.excerpt != "sharm then sharm" { t.Fatalf("match excerpt = %q", match.excerpt) } if want := []int{0, 1, 2, 3, 4}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] { t.Fatalf("highlight indexes = %v, want %v", match.indexes, want) } } func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) { d := newSessionDialog() d.active = true d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}} d.sessions = append([]core.SessionSummary(nil), d.baseSessions...) if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested { t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested) } if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" { t.Fatalf("one-character action = %#v, query = %q", action, d.query) } if len(d.sessions) != len(d.baseSessions) { t.Fatalf("one-character search filtered sessions: %#v", d.sessions) } if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil { t.Fatal("one-character search started the corpus reader") } if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" { t.Fatalf("two-character action = %#v, query = %q", action, d.query) } } func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) { d := newSessionDialog() d.active = true d.searchRequested = true d.searchReady = true d.query = "sh" d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}} d.sessions = append([]core.SessionSummary(nil), d.baseSessions...) d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}} d.HandleKey(tui.Key{Kind: tui.KeyBackspace}) if d.searchMatches != nil { t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches) } rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n")) if strings.Contains(rendered, "1 match") { t.Fatalf("one-character search rendered stale match count: %q", rendered) } } func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { first := newSessionDialog() firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) firstGeneration := first.loadGeneration secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) secondGeneration := first.loadGeneration if secondGeneration == firstGeneration { t.Fatal("reopening picker did not advance load generation") } first.ApplyLoad(sessionLoadEvent{ kind: sessionLoadStarted, generation: secondGeneration, total: 1, }) first.ApplyLoad(sessionLoadEvent{ kind: sessionLoadEntry, generation: firstGeneration, index: 0, summary: core.SessionSummary{ Path: "stale", MessageCount: 1, }, }) if len(first.sessions) != 0 { t.Fatalf("stale results repopulated picker: %+v", first.sessions) } first.Close() for range firstEvents { } for range secondEvents { } } func TestSessionDialogDeleteRequiresConfirmation(t *testing.T) { d := newSessionDialog() d.active = true d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl"}} if act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}); act.Delete { t.Fatal("delete hotkey immediately returned a delete action") } if !d.deleting { t.Fatal("delete hotkey did not open confirmation") } if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Delete { t.Fatal("enter confirmed deletion; want safe default cancellation") } if d.deleting { t.Fatal("enter did not close deletion confirmation") } if !d.Active() { t.Fatal("enter closed the picker; want the dialog to stay open") } d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) if act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}); act.Delete || act.Close { t.Fatalf("esc action = %+v, want silent cancellation", act) } if d.deleting { t.Fatal("esc did not close deletion confirmation") } if !d.Active() { t.Fatal("esc closed the picker; want the dialog to stay open") } d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'y'}) if !act.Delete || act.Path != "/sessions/one.jsonl" { t.Fatalf("confirmed action = %+v, want delete for selected path", act) } if len(d.sessions) != 1 { t.Fatal("dialog removed row before the host confirmed filesystem deletion") } } func TestSessionDialogDeleteConfirmationRender(t *testing.T) { d := newSessionDialog() d.active = true d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl", Title: "keep or delete"}} d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) plain := strings.Join(d.Render(tui.Theme{}, 100), "\n") for _, want := range []string{"permanently delete", "keep or delete", "press y to delete"} { if !strings.Contains(plain, want) { t.Fatalf("confirmation render missing %q:\n%s", want, plain) } } normal := &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: "/sessions/one.jsonl"}}} if got := strings.Join(normal.Render(tui.Theme{}, 100), "\n"); !strings.Contains(got, "d delete") { t.Fatalf("picker hint missing delete key:\n%s", got) } } func TestSessionDialogRemoveKeepsCursorValid(t *testing.T) { tests := []struct { name string cursor int removePath string wantCursor int wantPath string }{ {name: "first", cursor: 0, removePath: "a", wantCursor: 0, wantPath: "b"}, {name: "middle", cursor: 1, removePath: "b", wantCursor: 1, wantPath: "c"}, {name: "last", cursor: 2, removePath: "c", wantCursor: 1, wantPath: "b"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d := &sessionDialog{ sessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}}, baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}}, cursor: tt.cursor, } d.Remove(tt.removePath) if d.cursor != tt.wantCursor { t.Fatalf("cursor = %d, want %d", d.cursor, tt.wantCursor) } if got := d.sessions[d.cursor].Path; got != tt.wantPath { t.Fatalf("selected path = %q, want %q", got, tt.wantPath) } for _, summary := range d.baseSessions { if summary.Path == tt.removePath { t.Fatalf("removed path %q still in base sessions", tt.removePath) } } }) } d := &sessionDialog{sessions: []core.SessionSummary{{Path: "only"}}} d.Remove("only") if len(d.sessions) != 0 || d.cursor != 0 { t.Fatalf("empty dialog has len %d and cursor %d, want 0 and 0", len(d.sessions), d.cursor) } } func TestSessionDialogRemoveSurvivesLoadRebuild(t *testing.T) { d := &sessionDialog{ active: true, sessions: []core.SessionSummary{{Path: "b"}}, baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}}, cursor: 0, loadSlots: []sessionLoadSlot{ {loaded: true, summary: core.SessionSummary{Path: "a", MessageCount: 1}}, {loaded: true, summary: core.SessionSummary{Path: "b", MessageCount: 1}}, }, } d.Remove("a") d.rebuildLoadedSessions(2) for _, summary := range d.sessions { if summary.Path == "a" { t.Fatalf("deleted path resurrected by load rebuild: %+v", d.sessions) } } d.applySearchFilter() for _, summary := range d.sessions { if summary.Path == "a" { t.Fatalf("deleted path resurrected by search filter: %+v", d.sessions) } } } func TestApplySessionDeletionRemovesFileAndRow(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { t.Fatal(err) } i := &Interactive{ cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, } i.applySessionDeletion(path) if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("deleted session still exists: %v", err) } if len(i.sessionDialog.sessions) != 0 { t.Fatal("deleted session remains in picker") } if !strings.Contains(i.statusOK, "deleted session") || i.statusErr != "" { t.Fatalf("statusOK = %q, statusErr = %q", i.statusOK, i.statusErr) } } func TestApplySessionDeletionProtectsActiveSession(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { t.Fatal(err) } i := &Interactive{ cfg: InteractiveConfig{CurrentSessionPath: func() string { return path }}, sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, } i.applySessionDeletion(path) if _, err := os.Stat(path); err != nil { t.Fatalf("active session was deleted: %v", err) } if len(i.sessionDialog.sessions) != 1 { t.Fatal("active session was removed from picker") } if !strings.Contains(i.statusErr, "active session") { t.Fatalf("statusErr = %q, want active-session explanation", i.statusErr) } } func TestApplySessionDeletionRequiresCurrentSessionWiring(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") if err := os.WriteFile(path, []byte("session"), 0o600); err != nil { t.Fatal(err) } i := &Interactive{ sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, } i.applySessionDeletion(path) if _, err := os.Stat(path); err != nil { t.Fatalf("session was deleted without active-session wiring: %v", err) } if !strings.Contains(i.statusErr, "not wired") { t.Fatalf("statusErr = %q, want wiring error", i.statusErr) } } func TestApplySessionDeletionPreservesRowOnError(t *testing.T) { path := filepath.Join(t.TempDir(), "missing.jsonl") i := &Interactive{ cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }}, sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}}, } i.applySessionDeletion(path) if len(i.sessionDialog.sessions) != 1 { t.Fatal("failed deletion removed session from picker") } if !strings.Contains(i.statusErr, "delete session") { t.Fatalf("statusErr = %q, want deletion error", i.statusErr) } }