From 0bfb9f6f13db36c0db9aa9f8a764cb389fdd019f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 6 May 2026 16:43:35 +0200 Subject: [PATCH] feat: show agent turn summary in agents sidebar (#24942) Persists the agent-generated turn-end summary on `chats` and shows it as the Agents sidebar subtitle when present, falling back to the model name. Errors still take precedence. > Mux is acting on Mike's behalf. ## What changes **Storage.** New nullable `last_turn_summary` column on `chats` (migration `000486`). New `UpdateChatLastTurnSummary` query normalizes blank/whitespace input to `NULL`, preserves `updated_at` (so the chat does not jump to the top of the sidebar on summary writes), and uses an `expected_updated_at` stale-write guard so an older async summary cannot overwrite a newer turn. **Backend.** `coderd/x/chatd/chatd.go` decouples summary generation from webpush. Generated summaries persist for completed parent turns even when webpush is unconfigured or has no subscriptions. The same generated text is reused as the webpush body when webpush is configured, so the summary model is not called twice. Generic fallback push text is no longer persisted; it clears any stale summary instead. Error/interrupt/pending-action terminal paths clear `last_turn_summary` for the latest turn. **Frontend.** `AgentsSidebar.tsx` subtitle priority is now `errorReason || lastTurnSummary || modelName`, normalized via the existing `asNonEmptyString` helper from `blockUtils.ts`. ## Tests - `TestUpdateChatLastTurnSummary` (database): success, whitespace-to-NULL, stale guard rejects, `updated_at` preserved. - `TestUpdateLastTurnSummaryRejectsStaleWrites` (chatd internal): direct stale-`expected_updated_at` test. - `TestSuccessfulChatPersistsTurnSummaryWithoutWebPush`: persistence works without webpush subscriptions. - `TestSuccessfulChatSendsWebPushWithSummary`: same generated text drives both DB and push body. - `TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText`: fallback text is not persisted. - `TestErroredChatClearsLastTurnSummaryAndSendsWebPush`: error path clears the field. - `TestInterruptChatDoesNotSendWebPushNotification`: interrupt path clears the field, no push fires. - `AgentsSidebar.test.tsx`: subtitle priority for summary-present, error-wins, no-summary fallback, whitespace fallback. - `AgentsSidebar.stories.tsx`: `ChatWithTurnSummary` and `ChatWithTurnSummaryAndError`. ## Notes - No backfill. Existing chats keep showing the model name until their next turn completes. - Parent chats only in this iteration; the field is rendered on any `Chat` if a future change extends generation to children. - Decoupling generation from webpush adds quickgen model calls for completed parent turns that previously skipped generation when no subscriptions existed. Existing parent-only, assistant-text-present, `PushSummaryModel` configured, and bounded-timeout gates keep this behavior bounded. --- coderd/apidoc/docs.go | 5 + coderd/apidoc/swagger.json | 5 + coderd/database/db2sdk/db2sdk.go | 3 + coderd/database/db2sdk/db2sdk_test.go | 1 + coderd/database/dbauthz/dbauthz.go | 11 + coderd/database/dbauthz/dbauthz_test.go | 11 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 + coderd/database/dump.sql | 1 + .../000488_chat_last_turn_summary.down.sql | 1 + .../000488_chat_last_turn_summary.up.sql | 1 + coderd/database/modelqueries.go | 1 + coderd/database/models.go | 1 + coderd/database/querier.go | 10 + coderd/database/querier_test.go | 105 +++++++ coderd/database/queries.sql.go | 110 +++++-- coderd/database/queries/chats.sql | 19 ++ coderd/x/chatd/chatd.go | 244 ++++++++++++--- coderd/x/chatd/chatd_internal_test.go | 10 +- coderd/x/chatd/chatd_test.go | 284 ++++++++++++++++-- coderd/x/chatd/quickgen.go | 2 +- coderd/x/chatd/quickgen_test.go | 94 ++++++ coderd/x/chatd/title_override_test.go | 10 +- coderd/x/chatd/turn_summary_internal_test.go | 194 ++++++++++++ codersdk/chats.go | 6 +- docs/admin/security/audit-logs.md | 2 +- docs/reference/api/chats.md | 11 + docs/reference/api/schemas.md | 10 +- enterprise/audit/table.go | 1 + site/src/api/queries/chats.test.ts | 71 +++++ site/src/api/queries/chats.ts | 7 + site/src/api/typesGenerated.ts | 7 +- .../AgentsPage/AgentChatPage.stories.tsx | 1 + .../AgentsPage/AgentChatPageView.stories.tsx | 1 + .../AgentsPage/AgentsPageView.stories.tsx | 1 + .../ChatConversation/chatStore.test.tsx | 1 + .../components/ChatTopBar.stories.tsx | 1 + .../Sidebar/AgentsSidebar.stories.tsx | 48 +++ .../components/Sidebar/AgentsSidebar.test.tsx | 102 +++++++ .../components/Sidebar/AgentsSidebar.tsx | 12 +- 40 files changed, 1313 insertions(+), 115 deletions(-) create mode 100644 coderd/database/migrations/000488_chat_last_turn_summary.down.sql create mode 100644 coderd/database/migrations/000488_chat_last_turn_summary.up.sql create mode 100644 coderd/x/chatd/turn_summary_internal_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7d45a4efb2..be71ec44e1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15497,6 +15497,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "last_turn_summary": { + "type": "string" + }, "mcp_server_ids": { "type": "array", "items": { @@ -16357,6 +16360,7 @@ const docTemplate = `{ "type": "string", "enum": [ "status_change", + "summary_change", "title_change", "created", "deleted", @@ -16365,6 +16369,7 @@ const docTemplate = `{ ], "x-enum-varnames": [ "ChatWatchEventKindStatusChange", + "ChatWatchEventKindSummaryChange", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b6d9fcfce4..9f900a3435 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13938,6 +13938,9 @@ "type": "string", "format": "uuid" }, + "last_turn_summary": { + "type": "string" + }, "mcp_server_ids": { "type": "array", "items": { @@ -14769,6 +14772,7 @@ "type": "string", "enum": [ "status_change", + "summary_change", "title_change", "created", "deleted", @@ -14777,6 +14781,7 @@ ], "x-enum-varnames": [ "ChatWatchEventKindStatusChange", + "ChatWatchEventKindSummaryChange", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 325471f805..8e1ed20330 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1667,6 +1667,9 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database ClientType: codersdk.ChatClientType(c.ClientType), LastError: lastError, } + if c.LastTurnSummary.Valid { + chat.LastTurnSummary = &c.LastTurnSummary.String + } if c.PlanMode.Valid { chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode) } diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 7a0fa09483..ada8877a18 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -941,6 +941,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) { Status: database.ChatStatusRunning, ClientType: database.ChatClientTypeUi, LastError: pqtype.NullRawMessage{RawMessage: lastErrorRaw, Valid: true}, + LastTurnSummary: sql.NullString{String: "turn completed", Valid: true}, CreatedAt: now, UpdatedAt: now, Archived: true, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9badded7e0..d5e56e504e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6308,6 +6308,17 @@ func (q *querier) UpdateChatLastReadMessageID(ctx context.Context, arg database. return q.db.UpdateChatLastReadMessageID(ctx, arg) } +func (q *querier) UpdateChatLastTurnSummary(ctx context.Context, arg database.UpdateChatLastTurnSummaryParams) (int64, error) { + chat, err := q.db.GetChatByID(ctx, arg.ID) + if err != nil { + return 0, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return 0, err + } + return q.db.UpdateChatLastTurnSummary(ctx, arg) +} + func (q *querier) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) { chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 795a0e6641..e58d01264c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1532,6 +1532,17 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), arg).Return(chat, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat) })) + s.Run("UpdateChatLastTurnSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: chat.UpdatedAt, + LastTurnSummary: sql.NullString{String: "resolved the issue", Valid: true}, + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) + })) s.Run("UpdateChatLastReadMessageID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatLastReadMessageIDParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 125e86b2a4..d9a9963f9e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4544,6 +4544,14 @@ func (m queryMetricsStore) UpdateChatLastReadMessageID(ctx context.Context, arg return r0 } +func (m queryMetricsStore) UpdateChatLastTurnSummary(ctx context.Context, arg database.UpdateChatLastTurnSummaryParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatLastTurnSummary(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatLastTurnSummary").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastTurnSummary").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) { start := time.Now() r0, r1 := m.s.UpdateChatMCPServerIDs(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bfb29d8559..ead72d0945 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -8596,6 +8596,21 @@ func (mr *MockStoreMockRecorder) UpdateChatLastReadMessageID(ctx, arg any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastReadMessageID", reflect.TypeOf((*MockStore)(nil).UpdateChatLastReadMessageID), ctx, arg) } +// UpdateChatLastTurnSummary mocks base method. +func (m *MockStore) UpdateChatLastTurnSummary(ctx context.Context, arg database.UpdateChatLastTurnSummaryParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatLastTurnSummary", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatLastTurnSummary indicates an expected call of UpdateChatLastTurnSummary. +func (mr *MockStoreMockRecorder) UpdateChatLastTurnSummary(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastTurnSummary", reflect.TypeOf((*MockStore)(nil).UpdateChatLastTurnSummary), ctx, arg) +} + // UpdateChatMCPServerIDs mocks base method. func (m *MockStore) UpdateChatMCPServerIDs(ctx context.Context, arg database.UpdateChatMCPServerIDsParams) (database.Chat, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 491bbee16b..e7d192a0ca 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1451,6 +1451,7 @@ CREATE TABLE chats ( organization_id uuid NOT NULL, plan_mode chat_plan_mode, client_type chat_client_type DEFAULT 'api'::chat_client_type NOT NULL, + last_turn_summary text, CONSTRAINT chats_pin_order_archived_check CHECK (((pin_order = 0) OR (archived = false))), CONSTRAINT chats_pin_order_parent_check CHECK (((pin_order = 0) OR (parent_chat_id IS NULL))) ); diff --git a/coderd/database/migrations/000488_chat_last_turn_summary.down.sql b/coderd/database/migrations/000488_chat_last_turn_summary.down.sql new file mode 100644 index 0000000000..e74c61d51d --- /dev/null +++ b/coderd/database/migrations/000488_chat_last_turn_summary.down.sql @@ -0,0 +1 @@ +ALTER TABLE chats DROP COLUMN last_turn_summary; diff --git a/coderd/database/migrations/000488_chat_last_turn_summary.up.sql b/coderd/database/migrations/000488_chat_last_turn_summary.up.sql new file mode 100644 index 0000000000..cb2b9a5bf6 --- /dev/null +++ b/coderd/database/migrations/000488_chat_last_turn_summary.up.sql @@ -0,0 +1 @@ +ALTER TABLE chats ADD COLUMN last_turn_summary TEXT; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index c1d89c8a12..78331e338b 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -802,6 +802,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, &i.Chat.OrganizationID, &i.Chat.PlanMode, &i.Chat.ClientType, + &i.Chat.LastTurnSummary, &i.HasUnread); err != nil { return nil, err } diff --git a/coderd/database/models.go b/coderd/database/models.go index 65e6d5a142..a9dc787afb 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4380,6 +4380,7 @@ type Chat struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` } type ChatDebugRun struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 795c9a7af1..935453f2fc 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1114,6 +1114,16 @@ type sqlcQuerier interface { // Updates the last read message ID for a chat. This is used to track // which messages the owner has seen, enabling unread indicators. UpdateChatLastReadMessageID(ctx context.Context, arg UpdateChatLastReadMessageIDParams) error + // Updates the cached last completed turn summary for sidebar display. + // Empty or whitespace-only summaries are stored as NULL here so direct + // query callers cannot accidentally persist blank sidebar text. + // This intentionally preserves updated_at. The staleness guard relies on + // every new-turn query, such as UpdateChatStatus and AcquireChats, bumping + // updated_at. Future chat-field updates that do not bump updated_at can let + // stale summaries persist. If this query ever bumps updated_at, later + // goroutine summary writes will be rejected as stale. + // Two summary workers using the same freshness marker are last-write-wins. + UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 30ae724ff7..5cf790a4c5 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -11448,6 +11448,111 @@ func TestChatLabels(t *testing.T) { }) } +func TestUpdateChatLastTurnSummary(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + + ctx := testutil.Context(t, testutil.WaitMedium) + owner := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: owner.ID, OrganizationID: org.ID}) + + _, err = db.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "test-model", + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: owner.ID, + LastModelConfigID: modelCfg.ID, + Title: "summary-chat", + }) + require.NoError(t, err) + + affected, err := db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: chat.UpdatedAt, + LastTurnSummary: sql.NullString{String: "resolved the issue", Valid: true}, + }) + require.NoError(t, err) + require.EqualValues(t, 1, affected) + + fetched, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "resolved the issue", Valid: true}, fetched.LastTurnSummary) + require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) + + affected, err = db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: chat.UpdatedAt, + LastTurnSummary: sql.NullString{String: " \n\t ", Valid: true}, + }) + require.NoError(t, err) + require.EqualValues(t, 1, affected) + + fetched, err = db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.False(t, fetched.LastTurnSummary.Valid) + require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) + + affected, err = db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: chat.UpdatedAt, + LastTurnSummary: sql.NullString{String: "fresh summary", Valid: true}, + }) + require.NoError(t, err) + require.EqualValues(t, 1, affected) + + advancedUpdatedAt := chat.UpdatedAt.Add(time.Second) + _, err = db.UpdateChatStatusPreserveUpdatedAt(ctx, database.UpdateChatStatusPreserveUpdatedAtParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + UpdatedAt: advancedUpdatedAt, + }) + require.NoError(t, err) + + affected, err = db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: chat.UpdatedAt, + LastTurnSummary: sql.NullString{String: "stale summary", Valid: true}, + }) + require.NoError(t, err) + require.Zero(t, affected) + + fetched, err = db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary) + require.Equal(t, advancedUpdatedAt, fetched.UpdatedAt) +} + func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c18e34bb19..03fa2904a9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5120,7 +5120,7 @@ WHERE $3::int ) RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type AcquireChatsParams struct { @@ -5168,6 +5168,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ); err != nil { return nil, err } @@ -5306,9 +5307,9 @@ WITH chats AS ( UPDATE chats SET archived = true, pin_order = 0, updated_at = NOW() WHERE id = $1::uuid OR root_chat_id = $1::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC ` @@ -5350,6 +5351,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ); err != nil { return nil, err } @@ -5399,10 +5401,10 @@ archived AS ( FROM to_archive t WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children AND c.archived = false - RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type + RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary ) SELECT - a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, + a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, -- Children inherit their root's activity so last_activity_at is never null. COALESCE( t.last_activity_at, @@ -5447,6 +5449,7 @@ type AutoArchiveInactiveChatsRow struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } @@ -5492,6 +5495,7 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, &i.LastActivityAt, ); err != nil { return nil, err @@ -5642,7 +5646,7 @@ func (q *sqlQuerier) DeleteOldChats(ctx context.Context, arg DeleteOldChatsParam } const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats WHERE agent_id = $1::uuid AND archived = false @@ -5690,6 +5694,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ); err != nil { return nil, err } @@ -5706,7 +5711,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U const getChatByID = `-- name: GetChatByID :one SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats WHERE @@ -5744,12 +5749,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type FROM chats WHERE id = $1::uuid FOR UPDATE +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats WHERE id = $1::uuid FOR UPDATE ` func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) { @@ -5783,6 +5789,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -6890,7 +6897,7 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u const getChats = `-- name: GetChats :many SELECT - chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, + chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, chats.last_turn_summary, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats.id @@ -7011,6 +7018,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha &i.Chat.OrganizationID, &i.Chat.PlanMode, &i.Chat.ClientType, + &i.Chat.LastTurnSummary, &i.HasUnread, ); err != nil { return nil, err @@ -7027,7 +7035,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha } const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -7071,6 +7079,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ); err != nil { return nil, err } @@ -7155,7 +7164,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many SELECT - chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, + chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode, chats.client_type, chats.last_turn_summary, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats.id @@ -7227,6 +7236,7 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC &i.Chat.OrganizationID, &i.Chat.PlanMode, &i.Chat.ClientType, + &i.Chat.LastTurnSummary, &i.HasUnread, ); err != nil { return nil, err @@ -7293,7 +7303,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh const getStaleChats = `-- name: GetStaleChats :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats WHERE @@ -7344,6 +7354,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ); err != nil { return nil, err } @@ -7457,7 +7468,7 @@ INSERT INTO chats ( $16::chat_client_type ) RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type InsertChatParams struct { @@ -7527,6 +7538,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8062,9 +8074,9 @@ WITH chats AS ( archived = false, updated_at = NOW() WHERE id = $1::uuid OR root_chat_id = $1::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary FROM chats ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC ` @@ -8110,6 +8122,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ); err != nil { return nil, err } @@ -8190,7 +8203,7 @@ UPDATE chats SET updated_at = NOW() WHERE id = $3::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatBuildAgentBindingParams struct { @@ -8230,6 +8243,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8243,7 +8257,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatByIDParams struct { @@ -8282,6 +8296,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8340,7 +8355,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatLabelsByIDParams struct { @@ -8379,6 +8394,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8388,7 +8404,7 @@ UPDATE chats SET last_injected_context = $1::jsonb WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatLastInjectedContextParams struct { @@ -8431,6 +8447,7 @@ func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg Upda &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8444,7 +8461,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatLastModelConfigByIDParams struct { @@ -8483,6 +8500,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8505,6 +8523,40 @@ func (q *sqlQuerier) UpdateChatLastReadMessageID(ctx context.Context, arg Update return err } +const updateChatLastTurnSummary = `-- name: UpdateChatLastTurnSummary :execrows +UPDATE chats +SET + last_turn_summary = NULLIF(REGEXP_REPLACE( + $1::text, '^[[:space:]]+|[[:space:]]+$', '', 'g' + ), '') +WHERE + id = $2::uuid + AND updated_at = $3::timestamptz +` + +type UpdateChatLastTurnSummaryParams struct { + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` + ID uuid.UUID `db:"id" json:"id"` + ExpectedUpdatedAt time.Time `db:"expected_updated_at" json:"expected_updated_at"` +} + +// Updates the cached last completed turn summary for sidebar display. +// Empty or whitespace-only summaries are stored as NULL here so direct +// query callers cannot accidentally persist blank sidebar text. +// This intentionally preserves updated_at. The staleness guard relies on +// every new-turn query, such as UpdateChatStatus and AcquireChats, bumping +// updated_at. Future chat-field updates that do not bump updated_at can let +// stale summaries persist. If this query ever bumps updated_at, later +// goroutine summary writes will be rejected as stale. +// Two summary workers using the same freshness marker are last-write-wins. +func (q *sqlQuerier) UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateChatLastTurnSummary, arg.LastTurnSummary, arg.ID, arg.ExpectedUpdatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateChatMCPServerIDs = `-- name: UpdateChatMCPServerIDs :one UPDATE chats @@ -8514,7 +8566,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatMCPServerIDsParams struct { @@ -8553,6 +8605,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8684,7 +8737,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatPlanModeByIDParams struct { @@ -8723,6 +8776,7 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8740,7 +8794,7 @@ SET WHERE id = $6::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatStatusParams struct { @@ -8790,6 +8844,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8807,7 +8862,7 @@ SET WHERE id = $7::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatStatusPreserveUpdatedAtParams struct { @@ -8859,6 +8914,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8874,7 +8930,7 @@ SET WHERE id = $2::uuid RETURNING - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatTitleByIDParams struct { @@ -8913,6 +8969,7 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } @@ -8924,7 +8981,7 @@ UPDATE chats SET agent_id = $3::uuid, updated_at = NOW() WHERE id = $4::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary ` type UpdateChatWorkspaceBindingParams struct { @@ -8970,6 +9027,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.OrganizationID, &i.PlanMode, &i.ClientType, + &i.LastTurnSummary, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 4f3e6935ad..16c3b45da9 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -632,6 +632,25 @@ WHERE id = @id::uuid RETURNING *; +-- name: UpdateChatLastTurnSummary :execrows +-- Updates the cached last completed turn summary for sidebar display. +-- Empty or whitespace-only summaries are stored as NULL here so direct +-- query callers cannot accidentally persist blank sidebar text. +-- This intentionally preserves updated_at. The staleness guard relies on +-- every new-turn query, such as UpdateChatStatus and AcquireChats, bumping +-- updated_at. Future chat-field updates that do not bump updated_at can let +-- stale summaries persist. If this query ever bumps updated_at, later +-- goroutine summary writes will be rejected as stale. +-- Two summary workers using the same freshness marker are last-write-wins. +UPDATE chats +SET + last_turn_summary = NULLIF(REGEXP_REPLACE( + sqlc.narg('last_turn_summary')::text, '^[[:space:]]+|[[:space:]]+$', '', 'g' + ), '') +WHERE + id = @id::uuid + AND updated_at = @expected_updated_at::timestamptz; + -- name: UpdateChatMCPServerIDs :one UPDATE chats diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ff551bb76f..49df083dd4 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -67,6 +67,7 @@ const ( instructionCacheTTL = 5 * time.Minute workspaceDialValidationDelay = 5 * time.Second workspaceMCPDiscoveryTimeout = 5 * time.Second + turnSummaryWriteTimeout = 5 * time.Second // defaultDialTimeout matches the timeout used by ~8 other // server-side AgentConn callers. defaultDialTimeout = 30 * time.Second @@ -5509,12 +5510,21 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) { }) p.publishChatActionRequired(finishResult.updatedChat, runResult.PendingDynamicToolCalls) } - if !wasInterrupted { + if wasInterrupted { + p.maybeClearLastTurnSummaryAsync(cleanupCtx, finishResult.updatedChat, logger) + } else { lastErrorMessage := "" if lastErrorPayload != nil { lastErrorMessage = lastErrorPayload.Message } - p.maybeSendPushNotification(cleanupCtx, finishResult.updatedChat, status, lastErrorMessage, runResult, logger) + p.maybeFinalizeTurnSummaryAndPush( + cleanupCtx, + finishResult.updatedChat, + status, + lastErrorMessage, + runResult, + logger, + ) } }() @@ -5537,6 +5547,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) { logger.Info(ctx, "chat canceled during shutdown; returning to pending") status = database.ChatStatusPending lastErrorPayload = nil + wasInterrupted = true return } logger.Error(ctx, "failed to process chat", slog.Error(err)) @@ -5567,6 +5578,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) { logger.Info(ctx, "chat completed during shutdown; returning to pending") status = database.ChatStatusPending lastErrorPayload = nil + wasInterrupted = true return } } @@ -8251,12 +8263,9 @@ func parseDynamicToolNames(raw pqtype.NullRawMessage) (map[string]bool, error) { return names, nil } -// maybeSendPushNotification sends a web push notification when an -// agent chat reaches a terminal state. For errors it dispatches -// synchronously; for successful completions it spawns a goroutine -// that generates a short LLM summary before dispatching. The caller -// is responsible for skipping interrupted chats. -func (p *Server) maybeSendPushNotification( +// maybeFinalizeTurnSummaryAndPush updates the cached turn summary for +// parent chats and optionally sends a web push notification. +func (p *Server) maybeFinalizeTurnSummaryAndPush( ctx context.Context, chat database.Chat, status database.ChatStatus, @@ -8264,56 +8273,195 @@ func (p *Server) maybeSendPushNotification( runResult runChatResult, logger slog.Logger, ) { - if p.webpushDispatcher == nil || p.webpushDispatcher.PublicKey() == "" { - return - } if chat.ParentChatID.Valid { return } switch status { - case database.ChatStatusError: - pushBody := "Agent encountered an error." - if lastError != "" { - pushBody = lastError - } - p.dispatchPush(ctx, chat, pushBody, status, logger) - case database.ChatStatusWaiting: - // Generate a push notification summary asynchronously - // using a cheap LLM model. This avoids blocking the - // deferred cleanup path while still providing a - // meaningful notification body. - debugSvc := p.existingDebugService() - p.inflight.Add(1) - go func() { - defer p.inflight.Done() - pushCtx := context.WithoutCancel(ctx) - pushBody := "Agent has finished running." - assistantText := strings.TrimSpace(runResult.FinalAssistantText) - if assistantText != "" && runResult.PushSummaryModel != nil { - if summary := generatePushSummary( - pushCtx, - chat, - assistantText, - runResult.FallbackProvider, - runResult.FallbackModel, - runResult.PushSummaryModel, - runResult.ProviderKeys, - logger, - debugSvc, - runResult.TriggerMessageID, - runResult.HistoryTipMessageID, - ); summary != "" { - pushBody = summary - } - } + p.finalizeSuccessfulTurnSummaryAndPush(ctx, chat, runResult, logger) - p.dispatchPush(pushCtx, chat, pushBody, status, logger) - }() + case database.ChatStatusPending: + p.finalizeSuccessfulTurnSummary(ctx, chat, runResult, logger) + + case database.ChatStatusError: + p.clearLastTurnSummaryAsync(ctx, chat, logger) + if p.webpushConfigured() { + pushBody := "Agent encountered an error." + if lastError != "" { + pushBody = lastError + } + p.dispatchPush(ctx, chat, pushBody, status, logger) + } + + case database.ChatStatusRequiresAction: + p.clearLastTurnSummaryAsync(ctx, chat, logger) + + default: + // New statuses must be classified before they can safely + // preserve or finalize a cached turn summary. + p.clearLastTurnSummaryAsync(ctx, chat, logger) } } +func (p *Server) finalizeSuccessfulTurnSummary( + ctx context.Context, + chat database.Chat, + runResult runChatResult, + logger slog.Logger, +) { + p.finalizeSuccessfulTurnSummaryWithAfterFunc(ctx, chat, runResult, logger, func(context.Context, string) {}) +} + +func (p *Server) finalizeSuccessfulTurnSummaryAndPush( + ctx context.Context, + chat database.Chat, + runResult runChatResult, + logger slog.Logger, +) { + p.finalizeSuccessfulTurnSummaryWithAfterFunc(ctx, chat, runResult, logger, func(finalizeCtx context.Context, summary string) { + p.dispatchSuccessfulTurnPush(finalizeCtx, chat, summary, logger) + }) +} + +func (p *Server) finalizeSuccessfulTurnSummaryWithAfterFunc( + ctx context.Context, + chat database.Chat, + runResult runChatResult, + logger slog.Logger, + afterFinalize func(context.Context, string), +) { + debugSvc := p.existingDebugService() + // This helper runs during processChat cleanup, while processChat is + // still counted in p.inflight. Do not take inflightMu here because + // drainInflight holds it while waiting. + p.inflight.Go(func() { + finalizeCtx := context.WithoutCancel(ctx) + summary := "" + assistantText := strings.TrimSpace(runResult.FinalAssistantText) + if assistantText != "" && runResult.PushSummaryModel != nil { + summary = strings.TrimSpace(generatePushSummary( + finalizeCtx, + chat, + assistantText, + runResult.FallbackProvider, + runResult.FallbackModel, + runResult.PushSummaryModel, + runResult.ProviderKeys, + logger, + debugSvc, + runResult.TriggerMessageID, + runResult.HistoryTipMessageID, + )) + } + + shouldPersistSummary := summary != "" || chat.LastTurnSummary.Valid + if shouldPersistSummary { + p.updateLastTurnSummary(finalizeCtx, chat, chat.UpdatedAt, summary, logger) + } + + afterFinalize(finalizeCtx, summary) + }) +} + +func (p *Server) dispatchSuccessfulTurnPush( + ctx context.Context, + chat database.Chat, + summary string, + logger slog.Logger, +) { + if !p.webpushConfigured() { + return + } + pushBody := "Agent has finished running." + if summary != "" { + pushBody = summary + } + p.dispatchPush(ctx, chat, pushBody, database.ChatStatusWaiting, logger) +} + +func (p *Server) maybeClearLastTurnSummaryAsync( + ctx context.Context, + chat database.Chat, + logger slog.Logger, +) { + if chat.ParentChatID.Valid { + return + } + p.clearLastTurnSummaryAsync(ctx, chat, logger) +} + +func (p *Server) clearLastTurnSummaryAsync( + ctx context.Context, + chat database.Chat, + logger slog.Logger, +) { + if !chat.LastTurnSummary.Valid { + return + } + // This helper runs during processChat cleanup, while processChat is + // still counted in p.inflight. Do not take inflightMu here because + // drainInflight holds it while waiting. + p.inflight.Go(func() { + p.updateLastTurnSummary(context.WithoutCancel(ctx), chat, chat.UpdatedAt, "", logger) + }) +} + +// updateLastTurnSummary writes the cached sidebar summary for a chat. +// Callers should pass a detached context because this method is used for +// best-effort background cache writes. +func (p *Server) updateLastTurnSummary( + ctx context.Context, + chat database.Chat, + expectedUpdatedAt time.Time, + summary string, + logger slog.Logger, +) { + summary = strings.TrimSpace(summary) + lastTurnSummary := sql.NullString{String: summary, Valid: summary != ""} + + //nolint:gocritic // Narrow daemon access for best-effort summary cache writes. + updateCtx := dbauthz.AsChatd(ctx) + updateCtx, cancel := context.WithTimeout(updateCtx, turnSummaryWriteTimeout) + defer cancel() + + affected, err := p.db.UpdateChatLastTurnSummary(updateCtx, database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: expectedUpdatedAt, + LastTurnSummary: lastTurnSummary, + }) + if err != nil { + logger.Warn(updateCtx, "failed to update chat turn summary", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + return + } + if affected == 0 { + if summary != "" { + logger.Info(updateCtx, "skipped stale chat turn summary update with non-empty summary", + slog.F("chat_id", chat.ID), + slog.F("summary_length", len(summary)), + slog.F("expected_updated_at", expectedUpdatedAt), + ) + return + } + logger.Debug(updateCtx, "skipped stale chat turn summary update", + slog.F("chat_id", chat.ID), + slog.F("expected_updated_at", expectedUpdatedAt), + ) + return + } + + updatedChat := chat + updatedChat.LastTurnSummary = lastTurnSummary + p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindSummaryChange, nil) +} + +func (p *Server) webpushConfigured() bool { + return p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != "" +} + func (p *Server) dispatchPush( ctx context.Context, chat database.Chat, diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index bd1d33f788..093d2da517 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3442,7 +3442,11 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) { db.EXPECT().UpdateChatStatus(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, params database.UpdateChatStatusParams) (database.Chat, error) { finalStatus = params.Status - return database.Chat{ID: chatID, Status: params.Status}, nil + return database.Chat{ + ID: chatID, + Status: params.Status, + LastTurnSummary: sql.NullString{String: "previous summary", Valid: true}, + }, nil }, ) db.EXPECT().GetChatByID(gomock.Any(), chatID).Return( @@ -3450,6 +3454,8 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) { nil, ) + db.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), gomock.Any()).Return(int64(1), nil) + // resolveChatModel fails immediately — that's fine, we only // need processChat to get past initialization without being // interrupted by the stale notification. @@ -3475,6 +3481,8 @@ func TestProcessChat_IgnoresStaleControlNotification(t *testing.T) { // the status update itself races test teardown. testutil.TryReceive(ctx, t, done) + WaitUntilIdleForTest(server) + // If the stale notification interrupted us, status would be // "waiting" (the ErrInterrupted path). Since the gate blocked // it, processChat reached runChat, which failed on model diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 0283a95030..d160bbe8d1 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -4047,6 +4047,93 @@ func TestPersistToolResultWithBinaryData(t *testing.T) { require.True(t, foundToolResultInSecondCall, "expected second streamed model call to include execute tool output") } +func TestRequiresActionChatClearsLastTurnSummary(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("Dynamic tool test") + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAIToolCallChunk( + "my_dynamic_tool", + `{"input":"hello world"}`, + ), + ) + }) + + mockPush := &mockWebpushDispatcher{} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := chatd.New(chatd.Config{ + Logger: logger, + Database: db, + ReplicaID: uuid.New(), + Pubsub: ps, + PendingChatAcquireInterval: 10 * time.Millisecond, + InFlightChatStaleAfter: testutil.WaitSuperLong, + WebpushDispatcher: mockPush, + }) + t.Cleanup(func() { + require.NoError(t, server.Close()) + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + + dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{ + Name: "my_dynamic_tool", + Description: "A test dynamic tool.", + InputSchema: mcpgo.ToolInputSchema{ + Type: "object", + Properties: map[string]any{ + "input": map[string]any{"type": "string"}, + }, + Required: []string{"input"}, + }, + }}) + require.NoError(t, err) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "requires-action-summary-clear", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Please call the dynamic tool."), + }, + DynamicTools: dynamicToolsJSON, + }) + require.NoError(t, err) + seedLastTurnSummary(ctx, t, db, chat, "previous summary") + + server.Start() + + var fromDB database.Chat + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + got, dbErr := db.GetChatByID(ctx, chat.ID) + if dbErr != nil { + return false + } + fromDB = got + if got.Status == database.ChatStatusError { + return true + } + return got.Status == database.ChatStatusRequiresAction && + !got.LastTurnSummary.Valid + }, testutil.IntervalFast) + chatd.WaitUntilIdleForTest(server) + + require.Equal(t, database.ChatStatusRequiresAction, fromDB.Status, + "expected requires_action, got %s (last_error=%q)", + fromDB.Status, string(fromDB.LastError.RawMessage)) + require.False(t, fromDB.LastTurnSummary.Valid, + "requires action chats should clear cached turn summaries") + require.Equal(t, int32(0), mockPush.dispatchCount.Load(), + "expected no web push dispatch for a requires_action chat") +} + func TestDynamicToolCallPausesAndResumes(t *testing.T) { t.Parallel() @@ -5907,6 +5994,24 @@ func seedChatDependenciesWithProviderPolicy( return user, org, providerConfig, model } +func seedLastTurnSummary( + ctx context.Context, + t *testing.T, + db database.Store, + chat database.Chat, + summary string, +) { + t.Helper() + + affected, err := db.UpdateChatLastTurnSummary(ctx, database.UpdateChatLastTurnSummaryParams{ + ID: chat.ID, + ExpectedUpdatedAt: chat.UpdatedAt, + LastTurnSummary: sql.NullString{String: summary, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, int64(1), affected) +} + func waitForTerminalChatStatusEvent( ctx context.Context, t *testing.T, @@ -6121,7 +6226,6 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) { InFlightChatStaleAfter: testutil.WaitSuperLong, WebpushDispatcher: mockPush, }) - server.Start() t.Cleanup(func() { require.NoError(t, server.Close()) }) @@ -6137,6 +6241,9 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) { InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, }) require.NoError(t, err) + seedLastTurnSummary(ctx, t, db, chat, "previous summary") + + server.Start() // Wait for the chat to be picked up and start streaming. testutil.Eventually(ctx, t, func(ctx context.Context) bool { @@ -6168,6 +6275,12 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) { } return fromDB.Status == database.ChatStatusWaiting && !fromDB.WorkerID.Valid }, testutil.IntervalFast) + chatd.WaitUntilIdleForTest(server) + + fromDB, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.False(t, fromDB.LastTurnSummary.Valid, + "interrupted chats should clear cached turn summaries") // Verify no web push notification was dispatched. require.Equal(t, int32(0), mockPush.dispatchCount.Load(), @@ -6435,7 +6548,7 @@ func TestSuccessfulChatSendsWebPushWithSummary(t *testing.T) { user, org, model := seedChatDependencies(t, db) setOpenAIProviderBaseURL(ctx, t, db, openAIURL) - _, err := server.CreateChat(ctx, chatd.CreateOptions{ + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, Title: "summary-push-test", @@ -6447,19 +6560,71 @@ func TestSuccessfulChatSendsWebPushWithSummary(t *testing.T) { // The push notification is dispatched asynchronously after the // chat finishes, so we poll for it rather than checking // immediately after the status transitions to waiting. + var fromDB database.Chat testutil.Eventually(ctx, t, func(ctx context.Context) bool { - return mockPush.dispatchCount.Load() >= 1 + var dbErr error + fromDB, dbErr = db.GetChatByID(ctx, chat.ID) + return dbErr == nil && mockPush.dispatchCount.Load() >= 1 && fromDB.LastTurnSummary.Valid }, testutil.IntervalFast) msg := mockPush.getLastMessage() - require.Equal(t, summaryText, msg.Body, - "push body should be the LLM-generated summary") + require.Equal(t, summaryText, fromDB.LastTurnSummary.String, + "last turn summary should be the LLM-generated summary") + require.Equal(t, fromDB.LastTurnSummary.String, msg.Body, + "push body should reuse the persisted generated summary") require.NotEqual(t, "Agent has finished running.", msg.Body, "push body should not use the default fallback text") require.Equal(t, int32(1), nonStreamingRequests.Load(), "expected exactly one non-streaming request for push summary generation") } +func TestSuccessfulChatPersistsTurnSummaryWithoutWebPush(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + const assistantText = "I fixed the bug and added regression coverage." + const summaryText = "Fixed the bug and added regression coverage." + + var nonStreamingRequests atomic.Int32 + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + nonStreamingRequests.Add(1) + return chattest.OpenAINonStreamingResponse(summaryText) + } + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks(assistantText)..., + ) + }) + + server := newActiveTestServer(t, db, ps) + + user, org, model := seedChatDependencies(t, db) + setOpenAIProviderBaseURL(ctx, t, db, openAIURL) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "summary-no-webpush-test", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do the thing")}, + }) + require.NoError(t, err) + + var fromDB database.Chat + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + var dbErr error + fromDB, dbErr = db.GetChatByID(ctx, chat.ID) + return dbErr == nil && fromDB.LastTurnSummary.Valid + }, testutil.IntervalFast) + + require.Equal(t, summaryText, fromDB.LastTurnSummary.String, + "summary should persist even when web push is unavailable") + require.Equal(t, int32(1), nonStreamingRequests.Load(), + "expected exactly one non-streaming request for summary generation") +} + func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t *testing.T) { t.Parallel() @@ -6489,7 +6654,6 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t InFlightChatStaleAfter: testutil.WaitSuperLong, WebpushDispatcher: mockPush, }) - server.Start() t.Cleanup(func() { require.NoError(t, server.Close()) }) @@ -6497,7 +6661,7 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t user, org, model := seedChatDependencies(t, db) setOpenAIProviderBaseURL(ctx, t, db, openAIURL) - _, err := server.CreateChat(ctx, chatd.CreateOptions{ + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, Title: "empty-summary-push-test", @@ -6505,11 +6669,19 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do the thing")}, }) require.NoError(t, err) + seedLastTurnSummary(ctx, t, db, chat, "previous summary") + + server.Start() testutil.Eventually(ctx, t, func(ctx context.Context) bool { return mockPush.dispatchCount.Load() >= 1 }, testutil.IntervalFast) + fromDB, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.False(t, fromDB.LastTurnSummary.Valid, + "fallback push text should not be persisted") + msg := mockPush.getLastMessage() require.Equal(t, "Agent has finished running.", msg.Body, "push body should fall back when the final assistant text is empty") @@ -6517,6 +6689,68 @@ func TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText(t "push summary should not be requested when final assistant text has no usable text") } +func TestErroredChatClearsLastTurnSummaryAndSendsWebPush(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + return chattest.OpenAIErrorResponse(http.StatusBadRequest, "invalid_request_error", "Bad request") + }) + + mockPush := &mockWebpushDispatcher{} + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := chatd.New(chatd.Config{ + Logger: logger, + Database: db, + ReplicaID: uuid.New(), + Pubsub: ps, + PendingChatAcquireInterval: 10 * time.Millisecond, + InFlightChatStaleAfter: testutil.WaitSuperLong, + WebpushDispatcher: mockPush, + }) + t.Cleanup(func() { + require.NoError(t, server.Close()) + }) + + user, org, model := seedChatDependencies(t, db) + setOpenAIProviderBaseURL(ctx, t, db, openAIURL) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "error-summary-clear-test", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do the thing")}, + }) + require.NoError(t, err) + seedLastTurnSummary(ctx, t, db, chat, "previous summary") + + server.Start() + + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + fromDB, dbErr := db.GetChatByID(ctx, chat.ID) + return dbErr == nil && + fromDB.Status == database.ChatStatusError && + mockPush.dispatchCount.Load() >= 1 + }, testutil.IntervalFast) + chatd.WaitUntilIdleForTest(server) + + fromDB, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.False(t, fromDB.LastTurnSummary.Valid, + "errored chats should clear cached turn summaries") + + msg := mockPush.getLastMessage() + require.NotEqual(t, "Agent encountered an error.", msg.Body) + require.Contains(t, msg.Body, "OpenAI returned an unexpected error") +} + func TestComputerUseSubagentToolsAndModel(t *testing.T) { t.Parallel() @@ -6531,8 +6765,9 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { // computer use child chat). We use a raw HTTP handler because // the chattest AnthropicRequest struct does not capture tools. type anthropicCall struct { - Model string - Tools []string + Model string + Tools []string + Stream bool } var anthropicMu sync.Mutex var anthropicCalls []anthropicCall @@ -6563,8 +6798,9 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { } anthropicMu.Lock() anthropicCalls = append(anthropicCalls, anthropicCall{ - Model: req.Model, - Tools: names, + Model: req.Model, + Tools: names, + Stream: req.Stream, }) anthropicMu.Unlock() @@ -6737,11 +6973,15 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { got.Status != database.ChatStatusError { return false } - // Ensure the Anthropic mock received at least one call. + // Ensure the Anthropic mock received the child streaming call. anthropicMu.Lock() - n := len(anthropicCalls) - anthropicMu.Unlock() - return n >= 1 + defer anthropicMu.Unlock() + for _, call := range anthropicCalls { + if call.Stream { + return true + } + } + return false }, testutil.WaitLong, testutil.IntervalFast) anthropicMu.Lock() @@ -6751,8 +6991,18 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { require.NotEmpty(t, calls, "expected at least one Anthropic LLM call") - childModel := calls[0].Model - childTools := calls[0].Tools + var childCall anthropicCall + for _, call := range calls { + if call.Stream { + childCall = call + break + } + } + require.True(t, childCall.Stream, + "expected at least one streaming Anthropic child LLM call") + + childModel := childCall.Model + childTools := childCall.Tools // 1. Verify the model is the computer use model. require.Equal(t, computerUseModelName, childModel, diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 683be44dbe..e76545527e 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -248,7 +248,7 @@ func (p *Server) maybeGenerateChatTitle( return } - _, err = p.db.UpdateChatByID(ctx, database.UpdateChatByIDParams{ + _, err = p.db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: title, }) diff --git a/coderd/x/chatd/quickgen_test.go b/coderd/x/chatd/quickgen_test.go index 5d0b47b6ad..fb87a8a73b 100644 --- a/coderd/x/chatd/quickgen_test.go +++ b/coderd/x/chatd/quickgen_test.go @@ -11,9 +11,14 @@ import ( "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) func Test_extractManualTitleTurns(t *testing.T) { @@ -354,6 +359,95 @@ func Test_renderManualTitlePrompt(t *testing.T) { } } +func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + owner := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: owner.ID, + OrganizationID: org.ID, + }) + dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "openai", + Model: "test-model", + }) + + userPrompt := "summarize failed workspace build logs" + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: owner.ID, + LastModelConfigID: modelConfig.ID, + Title: fallbackChatTitle(userPrompt), + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + }) + + expectedUpdatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + chat, err := db.UpdateChatStatusPreserveUpdatedAt(ctx, database.UpdateChatStatusPreserveUpdatedAtParams{ + ID: chat.ID, + Status: chat.Status, + UpdatedAt: expectedUpdatedAt, + }) + require.NoError(t, err) + + const wantTitle = "Failed workspace logs" + model := &chattest.FakeModel{ + GenerateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + require.Equal(t, "propose_title", call.SchemaName) + return &fantasy.ObjectResponse{ + Object: map[string]any{"title": wantTitle}, + }, nil + }, + } + + message := mustChatMessage( + t, + database.ChatMessageRoleUser, + database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText(userPrompt), + ) + message.ID = 1 + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + generated := &generatedChatTitle{} + server := &Server{db: db} + server.maybeGenerateChatTitle( + ctx, + chat, + []database.ChatMessage{message}, + "openai", + "test-model", + model, + chatprovider.ProviderAPIKeys{}, + generated, + logger, + nil, + ) + + fetched, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, wantTitle, fetched.Title) + require.True(t, fetched.UpdatedAt.Equal(expectedUpdatedAt), + "updated_at = %s, want same instant as %s", + fetched.UpdatedAt, + expectedUpdatedAt, + ) + + gotTitle, ok := generated.Load() + require.True(t, ok) + require.Equal(t, wantTitle, gotTitle) +} + func Test_titleGenerationPrompt_UsesSlimRules(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/title_override_test.go b/coderd/x/chatd/title_override_test.go index 145f3c91d1..4fc0b2badc 100644 --- a/coderd/x/chatd/title_override_test.go +++ b/coderd/x/chatd/title_override_test.go @@ -51,7 +51,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) { } db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) - db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, }).Return(chatWithTitle(chat, wantTitle), nil) @@ -98,7 +98,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) { } db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) - db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, }).Return(chatWithTitle(chat, wantTitle), nil) @@ -146,7 +146,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideReadDBError(t *testing.T) } db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", sql.ErrConnDone) - db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, }).Return(chatWithTitle(chat, wantTitle), nil) @@ -193,7 +193,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideMalformedFallsThrough(t * } db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("not-a-uuid", nil) - db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, }).Return(chatWithTitle(chat, wantTitle), nil) @@ -247,7 +247,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) { db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{Provider: "openai"}}, nil) - db.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, }).Return(chatWithTitle(chat, wantTitle), nil) diff --git a/coderd/x/chatd/turn_summary_internal_test.go b/coderd/x/chatd/turn_summary_internal_test.go new file mode 100644 index 0000000000..fab0ed3e7f --- /dev/null +++ b/coderd/x/chatd/turn_summary_internal_test.go @@ -0,0 +1,194 @@ +package chatd + +import ( + "context" + "database/sql" + "encoding/json" + "sync/atomic" + "testing" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + owner := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: owner.ID, + OrganizationID: org.ID, + }) + + _, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "test-model", + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: owner.ID, + LastModelConfigID: modelCfg.ID, + Title: "summary-chat", + }) + require.NoError(t, err) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := &Server{db: db} + server.updateLastTurnSummary(ctx, chat, chat.UpdatedAt, "fresh summary", logger) + + fetched, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary) + + advancedUpdatedAt := chat.UpdatedAt.Add(time.Second) + _, err = db.UpdateChatStatusPreserveUpdatedAt(ctx, database.UpdateChatStatusPreserveUpdatedAtParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + UpdatedAt: advancedUpdatedAt, + }) + require.NoError(t, err) + + server.updateLastTurnSummary(context.WithoutCancel(ctx), chat, chat.UpdatedAt, "stale summary", logger) + + fetched, err = db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary) + require.Equal(t, advancedUpdatedAt, fetched.UpdatedAt) +} + +func TestPendingChatPersistsSummaryButSkipsWebPush(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + owner := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: owner.ID, + OrganizationID: org.ID, + }) + + _, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + require.NoError(t, err) + + modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{ + Provider: "openai", + Model: "test-model", + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusPending, + ClientType: database.ChatClientTypeUi, + OwnerID: owner.ID, + LastModelConfigID: modelCfg.ID, + Title: "summary-pending-chat", + }) + require.NoError(t, err) + + const summary = "Finished the queued turn." + model := &chattest.FakeModel{ + ProviderName: "openai", + ModelName: "test-model", + GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) { + return &fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.TextContent{Text: summary}, + }, + }, nil + }, + } + + dispatcher := &recordingWebpushDispatcher{} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := &Server{db: db, webpushDispatcher: dispatcher} + server.maybeFinalizeTurnSummaryAndPush( + context.WithoutCancel(ctx), + chat, + database.ChatStatusPending, + "", + runChatResult{ + FinalAssistantText: "I finished the queued turn.", + PushSummaryModel: model, + FallbackProvider: model.Provider(), + FallbackModel: model.Model(), + }, + logger, + ) + server.drainInflight() + + fetched, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: summary, Valid: true}, fetched.LastTurnSummary) + require.Equal(t, int32(0), dispatcher.dispatchCount.Load()) +} + +type recordingWebpushDispatcher struct { + dispatchCount atomic.Int32 +} + +func (d *recordingWebpushDispatcher) Dispatch( + _ context.Context, + _ uuid.UUID, + _ codersdk.WebpushMessage, +) error { + d.dispatchCount.Add(1) + return nil +} + +func (*recordingWebpushDispatcher) Test(_ context.Context, _ codersdk.WebpushSubscription) error { + return nil +} + +func (*recordingWebpushDispatcher) PublicKey() string { + return "test-vapid-public-key" +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 43d9e57b77..a0eab2868c 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -110,6 +110,7 @@ type Chat struct { Status ChatStatus `json:"status"` PlanMode ChatPlanMode `json:"plan_mode,omitempty"` LastError *ChatError `json:"last_error,omitempty"` + LastTurnSummary *string `json:"last_turn_summary"` DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` @@ -1594,6 +1595,7 @@ type ChatWatchEventKind string const ( ChatWatchEventKindStatusChange ChatWatchEventKind = "status_change" + ChatWatchEventKindSummaryChange ChatWatchEventKind = "summary_change" ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" ChatWatchEventKindCreated ChatWatchEventKind = "created" ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" @@ -1602,8 +1604,8 @@ const ( ) // ChatWatchEvent represents an event from the global chat watch stream. -// It delivers lifecycle events (created, status change, title change) -// for all of the authenticated user's chats. When Kind is +// It delivers lifecycle events (created, status change, summary change, +// title change) for all of the authenticated user's chats. When Kind is // ActionRequired, ToolCalls contains the pending dynamic tool // invocations the client must execute and submit back. type ChatWatchEvent struct { diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 60aa73ba70..1f028ff055 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -20,7 +20,7 @@ We track the following resources: | AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
worker_idfalse
workspace_idtrue
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
created_atfalse
dynamic_toolsfalse
heartbeat_atfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
parent_chat_idfalse
pin_ordertrue
plan_modefalse
root_chat_idfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
worker_idfalse
workspace_idtrue
| | CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index c352e2e4c7..296142fac6 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -140,6 +140,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -261,6 +262,7 @@ Status Code **200** | `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | | `»» url` | string | false | | | | `» last_model_config_id` | string(uuid) | false | | | +| `» last_turn_summary` | string | false | | | | `» mcp_server_ids` | array | false | | | | `» organization_id` | string(uuid) | false | | | | `» owner_id` | string(uuid) | false | | | @@ -468,6 +470,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -591,6 +594,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -865,6 +869,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -1042,6 +1047,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -1165,6 +1171,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -1423,6 +1430,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -1546,6 +1554,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -2583,6 +2592,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -2706,6 +2716,7 @@ Experimental: this endpoint is subject to change. } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 57c50a8d7b..99ca97beff 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2197,6 +2197,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -2320,6 +2321,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -2358,6 +2360,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | | | `last_injected_context` | array of [codersdk.ChatMessagePart](#codersdkchatmessagepart) | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | | `last_model_config_id` | string | false | | | +| `last_turn_summary` | string | false | | | | `mcp_server_ids` | array of string | false | | | | `organization_id` | string | false | | | | `owner_id` | string | false | | | @@ -3731,6 +3734,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in } ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], @@ -3777,9 +3781,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------| -| `action_required`, `created`, `deleted`, `diff_status_change`, `status_change`, `title_change` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------| +| `action_required`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | ## codersdk.ConnectionLatency diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 98f26b9100..1ccf6f4628 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -399,6 +399,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "last_model_config_id": ActionIgnore, // Churns every message. "archived": ActionTrack, "last_error": ActionIgnore, // Internal. + "last_turn_summary": ActionIgnore, // Internal cached display text. "mode": ActionTrack, "mcp_server_ids": ActionTrack, "labels": ActionTrack, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 5ba7549c31..7938e80742 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -112,6 +112,7 @@ const makeChat = ( pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, children: [], ...overrides, }); @@ -2027,6 +2028,57 @@ describe("mergeWatchedChatSummary", () => { ).toBe("22222222-2222-4222-8222-222222222222"); }); + it("merges last_turn_summary when watched updated_at equals cached updated_at", () => { + const cachedChat = makeChat("chat-1", { + last_turn_summary: "Previous summary", + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + last_turn_summary: "Updated summary", + updated_at: "2025-01-01T00:00:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "summary_change", + }).last_turn_summary, + ).toBe("Updated summary"); + }); + + it("applies summary_change even when event updated_at is older", () => { + const cachedChat = makeChat("chat-1", { + last_turn_summary: null, + updated_at: "2025-01-01T00:05:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + last_turn_summary: "Fixed the issue", + updated_at: "2025-01-01T00:00:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "summary_change", + }).last_turn_summary, + ).toBe("Fixed the issue"); + }); + + it("clears last_turn_summary on summary updates with matching updated_at", () => { + const cachedChat = makeChat("chat-1", { + last_turn_summary: "Previous summary", + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + last_turn_summary: null, + updated_at: "2025-01-01T00:00:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "summary_change", + }).last_turn_summary, + ).toBeNull(); + }); + it("compares updated_at values as instants instead of strings", () => { const cachedChat = makeChat("chat-1", { status: "pending", @@ -2216,6 +2268,25 @@ describe("mergeWatchedChatSummary", () => { ).toBe(true); }); + it("preserves has_unread for summary changes on inactive chats", () => { + const cachedChat = makeChat("chat-1", { + has_unread: false, + last_turn_summary: null, + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + last_turn_summary: "Updated summary", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "summary_change", + activeChatId: "chat-2", + }).has_unread, + ).toBe(false); + }); + it("preserves has_unread for the active chat", () => { const cachedChat = makeChat("chat-1", { has_unread: false, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 670cf68a16..89964a9d58 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -260,6 +260,7 @@ export const mergeWatchedChatSummary = ( ): TypesGen.Chat => { const isTitleEvent = eventKind === "title_change"; const isStatusEvent = eventKind === "status_change"; + const isSummaryEvent = eventKind === "summary_change"; const isDiffStatusEvent = eventKind === "diff_status_change"; const updatedAtComparison = compareUpdatedAtInstants( cachedChat.updated_at, @@ -286,6 +287,10 @@ export const mergeWatchedChatSummary = ( const nextLastModelConfigId = isFreshEnough ? watchedChat.last_model_config_id : cachedChat.last_model_config_id; + const nextLastTurnSummary = + isFreshEnough || isSummaryEvent + ? watchedChat.last_turn_summary + : cachedChat.last_turn_summary; const nextHasUnread = isFreshEnough && isStatusEvent && watchedChat.id !== activeChatId ? true @@ -303,6 +308,7 @@ export const mergeWatchedChatSummary = ( nextWorkspaceId === cachedChat.workspace_id && nextBuildId === cachedChat.build_id && nextLastModelConfigId === cachedChat.last_model_config_id && + nextLastTurnSummary === cachedChat.last_turn_summary && nextHasUnread === cachedChat.has_unread && nextUpdatedAt === cachedChat.updated_at ) { @@ -317,6 +323,7 @@ export const mergeWatchedChatSummary = ( workspace_id: nextWorkspaceId, build_id: nextBuildId, last_model_config_id: nextLastModelConfigId, + last_turn_summary: nextLastTurnSummary, has_unread: nextHasUnread, updated_at: nextUpdatedAt, }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7e0667e00c..bcbfa35ddb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1287,6 +1287,7 @@ export interface Chat { readonly status: ChatStatus; readonly plan_mode?: ChatPlanMode; readonly last_error?: ChatError; + readonly last_turn_summary: string | null; readonly diff_status?: ChatDiffStatus; readonly created_at: string; readonly updated_at: string; @@ -2712,8 +2713,8 @@ export interface ChatUsageLimitStatus { // From codersdk/chats.go /** * ChatWatchEvent represents an event from the global chat watch stream. - * It delivers lifecycle events (created, status change, title change) - * for all of the authenticated user's chats. When Kind is + * It delivers lifecycle events (created, status change, summary change, + * title change) for all of the authenticated user's chats. When Kind is * ActionRequired, ToolCalls contains the pending dynamic tool * invocations the client must execute and submit back. */ @@ -2730,6 +2731,7 @@ export type ChatWatchEventKind = | "deleted" | "diff_status_change" | "status_change" + | "summary_change" | "title_change"; export const ChatWatchEventKinds: ChatWatchEventKind[] = [ @@ -2738,6 +2740,7 @@ export const ChatWatchEventKinds: ChatWatchEventKind[] = [ "deleted", "diff_status_change", "status_change", + "summary_change", "title_change", ]; diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 7dc7d12d79..f246b0d132 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -137,6 +137,7 @@ const baseChatFields = { pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, children: [], } as const; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index bc8021b180..5efc885d6b 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -63,6 +63,7 @@ const buildChat = (overrides: Partial = {}): TypesGen.Chat => ({ pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, children: [], ...overrides, }); diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index f68f0f64b6..1596b69720 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -146,6 +146,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, children: [], ...overrides, }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index baf68d6c23..65f8a1d452 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -218,6 +218,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({ pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, children: [], }); diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 38dc30283d..c93ca7c9bb 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -65,6 +65,7 @@ export const WithParentChat: Story = { labels: {}, title: "Set up CI/CD pipeline", status: "completed", + last_turn_summary: null, created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", archived: false, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index cef02734a1..53ace0e7b2 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -69,6 +69,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, children: [], ...overrides, }); @@ -124,6 +125,53 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const ChatWithTurnSummary: Story = { + args: { + chats: [ + buildChat({ + id: "chat-turn-summary", + title: "Update workspace template", + last_turn_summary: "Added Docker and Terraform validation", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText("Added Docker and Terraform validation"), + ).toBeInTheDocument(); + expect(canvas.queryByText("GPT-4o")).not.toBeInTheDocument(); + }, +}; + +export const ChatWithTurnSummaryAndError: Story = { + args: { + chats: [ + buildChat({ + id: "chat-turn-summary-error", + title: "Fix workspace startup", + status: "error", + last_error: { + message: "Workspace startup failed", + retryable: false, + }, + last_turn_summary: "Recreated the workspace image", + }), + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText("Workspace startup failed"), + ).toBeInTheDocument(); + expect( + canvas.queryByText("Recreated the workspace image"), + ).not.toBeInTheDocument(); + }, +}; + export const RunningDelegatedChat: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index 946dca2748..df079ab99d 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -66,6 +66,7 @@ const buildChat = (overrides: Partial = {}): Chat => ({ pin_order: 0, has_unread: false, client_type: "ui", + last_turn_summary: null, mcp_server_ids: [], labels: {}, children: [], @@ -522,3 +523,104 @@ describe("AgentsSidebar model display names", () => { expect(queryByText("Default model")).not.toBeInTheDocument(); }); }); + +describe("AgentsSidebar subtitles", () => { + const modelOptions = [ + { + id: "model-1", + provider: "openai", + model: "gpt-4o", + displayName: "GPT-4o", + }, + ]; + + it("shows the last turn summary when present and no error exists", () => { + render( + + + , + ); + + expect( + screen.getByText("Updated the Terraform template"), + ).toBeInTheDocument(); + expect(screen.queryByText("GPT-4o")).not.toBeInTheDocument(); + }); + + it("shows the error when both error and last turn summary exist", () => { + render( + + + , + ); + + expect(screen.getByText("Workspace startup failed")).toBeInTheDocument(); + expect( + screen.queryByText("Provisioned a workspace"), + ).not.toBeInTheDocument(); + }); + + it("falls back to the model name when no last turn summary exists", () => { + render( + + + , + ); + + expect(screen.getByText("GPT-4o")).toBeInTheDocument(); + }); + + it("falls back to the model name when the last turn summary is blank", () => { + render( + + + , + ); + + expect(screen.getByText("GPT-4o")).toBeInTheDocument(); + }); +}); diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index 9d38553fa7..eb92d438e9 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -105,6 +105,7 @@ import { cn } from "#/utils/cn"; import { shortRelativeTime } from "#/utils/time"; import { getNormalizedModelRef } from "../../utils/modelOptions"; import { getTimeGroup, TIME_GROUPS } from "../../utils/timeGroups"; +import { asNonEmptyString } from "../ChatConversation/blockUtils"; import type { ModelSelectorOption } from "../ChatElements"; import { asString } from "../ChatElements/runtimeTypeUtils"; import { UsageIndicator } from "../UsageIndicator"; @@ -241,14 +242,6 @@ const getPRIconConfig = ( return { icon: GitPullRequestArrowIcon, className: "text-git-added-bright" }; }; -const asNonEmptyString = (value: unknown): string | undefined => { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -}; - const getModelDisplayName = ( lastModelConfigID: Chat["last_model_config_id"] | undefined, modelConfigs: readonly ChatModelConfig[], @@ -506,7 +499,8 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => { chat.status === "error" ? chatErrorReasons[chat.id] || chat.last_error?.message || undefined : undefined; - const subtitle = errorReason || modelName; + const lastTurnSummary = asNonEmptyString(chat.last_turn_summary); + const subtitle = errorReason || lastTurnSummary || modelName; const diffStatus = getChatDiffStatus(chat); const baseConfig = getStatusConfig(chat.status); const prConfig =