Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}}
}
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions internal/session/input_preparer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
35 changes: 35 additions & 0 deletions internal/session/sqlite_store_additional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions internal/tui/core/app/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading