diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 8a84584b..eac56ede 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -41,12 +41,55 @@ type failingStore struct { ignoreContextErr bool } +type listSessionsStubStore struct { + summaries []agentsession.Summary + loadErr error + session agentsession.Session +} + type budgetResolverFunc func(ctx context.Context, cfg config.Config) (int, string, error) func (f budgetResolverFunc) ResolvePromptBudget(ctx context.Context, cfg config.Config) (int, string, error) { return f(ctx, cfg) } +func (s *listSessionsStubStore) CreateSession(ctx context.Context, input agentsession.CreateSessionInput) (agentsession.Session, error) { + return agentsession.Session{}, errors.New("not implemented") +} + +func (s *listSessionsStubStore) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + if s.loadErr != nil { + return agentsession.Session{}, s.loadErr + } + return cloneSession(s.session), nil +} + +func (s *listSessionsStubStore) ListSummaries(ctx context.Context) ([]agentsession.Summary, error) { + out := make([]agentsession.Summary, len(s.summaries)) + copy(out, s.summaries) + return out, nil +} + +func (s *listSessionsStubStore) AppendMessages(ctx context.Context, input agentsession.AppendMessagesInput) error { + return errors.New("not implemented") +} + +func (s *listSessionsStubStore) UpdateSessionState(ctx context.Context, input agentsession.UpdateSessionStateInput) error { + return errors.New("not implemented") +} + +func (s *listSessionsStubStore) UpdateSessionWorkdir(ctx context.Context, input agentsession.UpdateSessionWorkdirInput) error { + return errors.New("not implemented") +} + +func (s *listSessionsStubStore) ReplaceTranscript(ctx context.Context, input agentsession.ReplaceTranscriptInput) error { + return errors.New("not implemented") +} + +func (s *listSessionsStubStore) CleanupExpiredSessions(ctx context.Context, maxAge time.Duration) (int, error) { + return 0, nil +} + func newMemoryStore() *memoryStore { return &memoryStore{sessions: map[string]agentsession.Session{}} } @@ -3419,6 +3462,74 @@ func TestServiceListSessionsPromotesDefaultTitlesFromUserMessages(t *testing.T) } } +func TestServiceListSessionsSkipsPromotionWhenDerivedTitleInvalid(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, tools.NewRegistry(), store, nil, nil) + + imageOnly := agentsession.New("New Session") + imageOnly.Messages = []providertypes.Message{ + { + Role: providertypes.RoleUser, + Parts: []providertypes.ContentPart{ + providertypes.NewRemoteImagePart("https://example.com/image.png"), + }, + }, + } + store.sessions[imageOnly.ID] = cloneSession(imageOnly) + + summaries, err := service.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(summaries) != 1 { + t.Fatalf("expected 1 summary, got %d", len(summaries)) + } + if summaries[0].Title != "New Session" { + t.Fatalf("expected default title to stay unchanged, got %q", summaries[0].Title) + } +} + +func TestRuntimeSessionTitlePromotionHelpers(t *testing.T) { + t.Parallel() + + if got := sessionTitleFromMessages([]providertypes.Message{ + {Role: providertypes.RoleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("a")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewRemoteImagePart("https://example.com/image.png")}}, + }); got != "" { + t.Fatalf("expected empty title for image-only user message, got %q", got) + } + + if !isDefaultSessionTitle(" new session ") { + t.Fatalf("expected default title to match ignoring case and spaces") + } + if !isImageOnlySessionTitle(" image message ") { + t.Fatalf("expected image-only title to match ignoring case and spaces") + } + if shouldPromoteSessionTitle("Already Named", "new title") { + t.Fatalf("expected non-default current title not to be promoted") + } + if shouldPromoteSessionTitle("New Session", "Image Message") { + t.Fatalf("expected image-only derived title not to be promoted") + } +} + +func TestServiceListSessionsKeepsDefaultOnLoadError(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := &listSessionsStubStore{ + summaries: []agentsession.Summary{{ID: "s1", Title: "New Session"}}, + loadErr: errors.New("load failed"), + } + service := NewWithFactory(manager, tools.NewRegistry(), store, nil, nil) + summaries, err := service.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if got := summaries[0].Title; got != "New Session" { + t.Fatalf("expected default title unchanged on load error, got %q", got) + } +} + func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { manager := newRuntimeConfigManager(t) defaultWorkdir := t.TempDir() diff --git a/internal/session/input_preparer_test.go b/internal/session/input_preparer_test.go index 8209852c..d4552779 100644 --- a/internal/session/input_preparer_test.go +++ b/internal/session/input_preparer_test.go @@ -571,6 +571,31 @@ func TestInputPreparerPrepareKeepsExistingNonDefaultTitle(t *testing.T) { } } +func TestInputPreparerShouldPromoteSessionTitle(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + current string + next string + want bool + }{ + {name: "promote default", current: "New Session", next: "real title", want: true}, + {name: "reject empty", current: "New Session", next: " ", want: false}, + {name: "reject default next", current: "New Session", next: "new session", want: false}, + {name: "reject non-default current", current: "Named", next: "other", want: false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := shouldPromoteSessionTitle(tc.current, tc.next); got != tc.want { + t.Fatalf("shouldPromoteSessionTitle(%q,%q)=%v, want %v", tc.current, tc.next, got, tc.want) + } + }) + } +} + func TestInputPreparerPrepareWorkdirUpdatePreservesConcurrentSessionHeadChanges(t *testing.T) { t.Parallel() diff --git a/internal/session/sqlite_store_additional_test.go b/internal/session/sqlite_store_additional_test.go index 7997d9bf..7753fe54 100644 --- a/internal/session/sqlite_store_additional_test.go +++ b/internal/session/sqlite_store_additional_test.go @@ -39,6 +39,9 @@ func TestSQLiteStoreMethodsRespectCanceledContext(t *testing.T) { if err := store.UpdateSessionState(ctx, UpdateSessionStateInput{SessionID: "cancel_ctx", Title: "x"}); err == nil { t.Fatalf("expected UpdateSessionState canceled error") } + if err := store.UpdateSessionTitle(ctx, UpdateSessionTitleInput{SessionID: "cancel_ctx", Title: "x"}); err == nil { + t.Fatalf("expected UpdateSessionTitle canceled error") + } if err := store.ReplaceTranscript(ctx, ReplaceTranscriptInput{SessionID: "cancel_ctx"}); err == nil { t.Fatalf("expected ReplaceTranscript canceled error") } @@ -62,11 +65,43 @@ func TestSQLiteStoreMethodsRejectInvalidSessionID(t *testing.T) { if err := store.UpdateSessionState(ctx, UpdateSessionStateInput{SessionID: "bad/id", Title: "x"}); err == nil { t.Fatalf("expected UpdateSessionState invalid id error") } + if err := store.UpdateSessionTitle(ctx, UpdateSessionTitleInput{SessionID: "bad/id", Title: "x"}); err == nil { + t.Fatalf("expected UpdateSessionTitle invalid id error") + } if err := store.ReplaceTranscript(ctx, ReplaceTranscriptInput{SessionID: "bad/id"}); err == nil { t.Fatalf("expected ReplaceTranscript invalid id error") } } +func TestSQLiteStoreUpdateSessionTitlePersistsSanitizedTitle(t *testing.T) { + t.Parallel() + + store := newTestStore(t) + created, err := store.CreateSession(context.Background(), CreateSessionInput{ + ID: "title_update_case", + Title: "New Session", + }) + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + updatedAt := created.UpdatedAt.Add(time.Minute) + if err := store.UpdateSessionTitle(context.Background(), UpdateSessionTitleInput{ + SessionID: created.ID, + UpdatedAt: updatedAt, + Title: " line1 \n\t line2 ", + }); err != nil { + t.Fatalf("UpdateSessionTitle() error = %v", err) + } + + loaded, err := store.LoadSession(context.Background(), created.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if loaded.Title != "line1 line2" { + t.Fatalf("expected sanitized single-line title, got %q", loaded.Title) + } +} + func TestSQLiteHelperBranches(t *testing.T) { if got, err := normalizeMessages(nil); err != nil || got != nil { t.Fatalf("normalizeMessages(nil) = (%v, %v), want (nil, nil)", got, err) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index a12bb99e..c83c5f9b 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -2093,6 +2093,82 @@ func TestUpdatePasteLoadingClearsAfterLoadReadyWithoutSend(t *testing.T) { } } +func TestUpdatePastedTextLoadReadyReschedulesWhenPasteTxnActive(t *testing.T) { + app, _ := newTestApp(t) + app.loadingPastedText = true + app.pasteTxnActive = true + + model, cmd := app.Update(pastedTextLoadReadyMsg{}) + app = model.(App) + if !app.loadingPastedText { + t.Fatalf("expected loading state to remain when paste txn is active") + } + if cmd == nil { + t.Fatalf("expected re-schedule command when pasted text is not ready") + } +} + +func TestUpdatePastedTextLoadReadyAppendsSeparatorBeforeDeferredSend(t *testing.T) { + app, runtime := newTestApp(t) + app.loadingPastedText = true + app.pendingSendAfterPasteLoad = true + app.input.SetValue("hello") + app.state.InputText = app.input.Value() + + model, cmd := app.Update(pastedTextLoadReadyMsg{}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + + if len(runtime.prepareInputs) != 1 { + t.Fatalf("expected deferred send to run once, got %d", len(runtime.prepareInputs)) + } + if runtime.prepareInputs[0].Text != "hello" { + t.Fatalf("unexpected deferred send payload: %q", runtime.prepareInputs[0].Text) + } + if app.loadingPastedText || app.pendingSendAfterPasteLoad { + t.Fatalf("expected loading flags cleared after deferred send") + } +} + +func TestPasteSessionHelpers(t *testing.T) { + app, _ := newTestApp(t) + now := time.Now() + + app.extendPasteSession(now, 0) + if app.pasteSessionStartedAt.IsZero() || app.pasteSessionUntil.IsZero() { + t.Fatalf("expected paste session window to be initialized") + } + if !app.inPasteSessionWindow(now.Add(200 * time.Millisecond)) { + t.Fatalf("expected to stay within paste session window") + } + + app.markPasteSessionToken(" token ") + if !app.pasteTxnTokenInjected || app.pasteTxnInjectedToken != "token" { + t.Fatalf("expected token to be normalized and recorded") + } + + app.pendingTextPastes = []pendingTextPaste{{Token: "token", Loaded: false}} + if token, ok := app.reuseSinglePasteSessionToken(now.Add(100 * time.Millisecond)); !ok || token != "token" { + t.Fatalf("expected to reuse pinned paste token within session window") + } + + app.pasteSessionUntil = now.Add(100 * time.Millisecond) + if _, ok := app.reuseSinglePasteSessionToken(now.Add(2 * time.Second)); ok { + t.Fatalf("expected token reuse to stop outside paste session window") + } + + app.pendingCtrlVPasteEcho = "echo" + if app.shouldCompletePastedTextLoading(now.Add(100 * time.Millisecond)) { + t.Fatalf("expected pending ctrl+v echo to block completion") + } + app.pendingCtrlVPasteEcho = "" + if !app.shouldCompletePastedTextLoading(now.Add(200 * time.Millisecond)) { + t.Fatalf("expected completion after ctrl+v echo settles") + } +} + func TestUpdateDirectPasteEventKeepsIncomingPayload(t *testing.T) { app, _ := newTestApp(t) pasted := "a\nb\nc\nd\ne"