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 =