From b7f470f32d7bf1996d6404bbbaa48f07035fa770 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 24 Jun 2026 10:57:19 +0000 Subject: [PATCH 01/24] feat: add persisted whole-chat summary with background generation Add a persisted chats.summary populated by a background generator after successful root-chat turns, delivered live via a new chat_summary_change watch event, with per-feature cost attribution and a deployment-wide summary-generation model override. --- coderd/apidoc/docs.go | 6 + coderd/apidoc/swagger.json | 6 + coderd/database/db2sdk/db2sdk.go | 3 + coderd/database/db2sdk/db2sdk_test.go | 1 + coderd/database/dbauthz/dbauthz.go | 41 +++ coderd/database/dbauthz/dbauthz_test.go | 31 ++ coderd/database/dbmetrics/querymetrics.go | 32 ++ coderd/database/dbmock/dbmock.go | 59 ++++ coderd/database/dump.sql | 7 +- .../migrations/000530_chat_summary.down.sql | 58 +++ .../migrations/000530_chat_summary.up.sql | 69 ++++ coderd/database/modelqueries.go | 4 + coderd/database/models.go | 7 +- coderd/database/querier.go | 17 + coderd/database/querier_test.go | 209 +++++++++++ coderd/database/queries.sql.go | 314 +++++++++++++---- coderd/database/queries/chats.sql | 70 ++++ coderd/database/queries/siteconfig.sql | 8 + coderd/exp_chats.go | 6 + coderd/exp_chats_test.go | 10 + coderd/x/chatd/chatd.go | 329 ++++++++++++++++++ coderd/x/chatd/chatd_internal_test.go | 8 + coderd/x/chatd/quickgen.go | 210 +++++++++++ coderd/x/chatd/summary_override.go | 102 ++++++ .../x/chatd/summary_override_internal_test.go | 120 +++++++ coderd/x/chatd/summarygen_internal_test.go | 194 +++++++++++ codersdk/chats.go | 74 ++-- docs/admin/security/audit-logs.md | 70 ++-- docs/reference/api/chats.md | 15 + docs/reference/api/schemas.md | 10 +- enterprise/audit/table.go | 2 + site/src/api/queries/chats.test.ts | 24 ++ site/src/api/queries/chats.ts | 11 + site/src/api/typesGenerated.ts | 10 + .../AgentsPage/AgentChatPage.stories.tsx | 1 + .../AgentsPage/AgentSettingsAgentsPage.tsx | 20 ++ .../AgentSettingsAgentsPageView.stories.tsx | 49 +++ .../AgentSettingsAgentsPageView.tsx | 33 ++ .../AgentsPage/AgentsPageView.stories.tsx | 8 + .../components/ChatTopBar.stories.tsx | 1 + .../dialogs/ChatSearchDialog.stories.tsx | 1 + site/src/testHelpers/chatEntities.ts | 1 + 42 files changed, 2121 insertions(+), 130 deletions(-) create mode 100644 coderd/database/migrations/000530_chat_summary.down.sql create mode 100644 coderd/database/migrations/000530_chat_summary.up.sql create mode 100644 coderd/x/chatd/summary_override.go create mode 100644 coderd/x/chatd/summary_override_internal_test.go create mode 100644 coderd/x/chatd/summarygen_internal_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b9ac6429025..1801d517cd5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16704,6 +16704,10 @@ const docTemplate = `{ "status": { "$ref": "#/definitions/codersdk.ChatStatus" }, + "summary": { + "description": "Summary is the persisted whole-chat summary shown in the chat summary\npopover. It is generated asynchronously in the background and may be nil\nuntil the first summary has been produced.", + "type": "string" + }, "title": { "type": "string" }, @@ -17820,6 +17824,7 @@ const docTemplate = `{ "enum": [ "status_change", "summary_change", + "chat_summary_change", "title_change", "created", "deleted", @@ -17830,6 +17835,7 @@ const docTemplate = `{ "x-enum-varnames": [ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", + "ChatWatchEventKindChatSummaryChange", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 73ed128a325..716e51dd9d8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15010,6 +15010,10 @@ "status": { "$ref": "#/definitions/codersdk.ChatStatus" }, + "summary": { + "description": "Summary is the persisted whole-chat summary shown in the chat summary\npopover. It is generated asynchronously in the background and may be nil\nuntil the first summary has been produced.", + "type": "string" + }, "title": { "type": "string" }, @@ -16073,6 +16077,7 @@ "enum": [ "status_change", "summary_change", + "chat_summary_change", "title_change", "created", "deleted", @@ -16083,6 +16088,7 @@ "x-enum-varnames": [ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", + "ChatWatchEventKindChatSummaryChange", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 2e4698b67c8..d13c5a4f751 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1703,6 +1703,9 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database if c.LastTurnSummary.Valid { chat.LastTurnSummary = &c.LastTurnSummary.String } + if c.Summary.Valid { + chat.Summary = &c.Summary.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 52eda2adc55..2734cf5eda7 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -712,6 +712,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) { ClientType: database.ChatClientTypeUi, LastError: pqtype.NullRawMessage{RawMessage: lastErrorRaw, Valid: true}, LastTurnSummary: sql.NullString{String: "turn completed", Valid: true}, + Summary: sql.NullString{String: "summarized the whole chat", Valid: true}, CreatedAt: now, UpdatedAt: now, Archived: true, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5fe1cd7e995..7074f3e8f72 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3432,6 +3432,13 @@ func (q *querier) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([ return q.db.GetChatStreamSyncRows(ctx, ids) } +func (q *querier) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return "", err + } + return q.db.GetChatSummaryGenerationModelOverride(ctx) +} + func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) { // The system prompt is a deployment-wide setting read during chat // creation by every authenticated user, so no RBAC policy check @@ -7183,6 +7190,22 @@ func (q *querier) UpdateChatMessageByID(ctx context.Context, arg database.Update return q.db.UpdateChatMessageByID(ctx, arg) } +func (q *querier) UpdateChatMessageCostSource(ctx context.Context, arg database.UpdateChatMessageCostSourceParams) (int64, error) { + // Authorize update on the parent chat of the tagged message. + msg, err := q.db.GetChatMessageByID(ctx, arg.ID) + if err != nil { + return 0, err + } + chat, err := q.db.GetChatByID(ctx, msg.ChatID) + if err != nil { + return 0, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return 0, err + } + return q.db.UpdateChatMessageCostSource(ctx, arg) +} + func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.ChatModelConfig{}, err @@ -7249,6 +7272,17 @@ func (q *querier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg dat return q.db.UpdateChatStatusPreserveUpdatedAt(ctx, arg) } +func (q *querier) UpdateChatSummary(ctx context.Context, arg database.UpdateChatSummaryParams) (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.UpdateChatSummary(ctx, arg) +} + func (q *querier) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) { chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { @@ -8644,6 +8678,13 @@ func (q *querier) UpsertChatRetentionDays(ctx context.Context, retentionDays int return q.db.UpsertChatRetentionDays(ctx, retentionDays) } +func (q *querier) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatSummaryGenerationModelOverride(ctx, value) +} + func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 0cc603d4c5c..4f089236ef1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1194,6 +1194,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) })) + s.Run("GetChatSummaryGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) + })) s.Run("GetChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) @@ -1511,6 +1515,18 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatMessageByID(gomock.Any(), arg).Return(updated, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(updated) })) + s.Run("UpdateChatMessageCostSource", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) + arg := database.UpdateChatMessageCostSourceParams{ + ID: msg.ID, + CostSource: "summary", + } + dbm.EXPECT().GetChatMessageByID(gomock.Any(), msg.ID).Return(msg, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatMessageCostSource(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) + })) s.Run("UpdateChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) arg := database.UpdateChatModelConfigParams{ @@ -1654,6 +1670,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatTitleGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("UpsertChatSummaryGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatSummaryGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() + check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpsertChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) @@ -1910,6 +1930,17 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatLastTurnSummary(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) })) + s.Run("UpdateChatSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "summarized the whole chat", Valid: true}, + } + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().UpdateChatSummary(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 40820d9cfb8..58ca82d5d56 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1754,6 +1754,14 @@ func (m queryMetricsStore) GetChatStreamSyncRows(ctx context.Context, ids []uuid return r0, r1 } +func (m queryMetricsStore) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetChatSummaryGenerationModelOverride(ctx) + m.queryLatencies.WithLabelValues("GetChatSummaryGenerationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSummaryGenerationModelOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetChatSystemPrompt(ctx) @@ -5154,6 +5162,14 @@ func (m queryMetricsStore) UpdateChatMessageByID(ctx context.Context, arg databa return r0, r1 } +func (m queryMetricsStore) UpdateChatMessageCostSource(ctx context.Context, arg database.UpdateChatMessageCostSourceParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatMessageCostSource(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatMessageCostSource").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatMessageCostSource").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { start := time.Now() r0, r1 := m.s.UpdateChatModelConfig(ctx, arg) @@ -5202,6 +5218,14 @@ func (m queryMetricsStore) UpdateChatStatusPreserveUpdatedAt(ctx context.Context return r0, r1 } +func (m queryMetricsStore) UpdateChatSummary(ctx context.Context, arg database.UpdateChatSummaryParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.UpdateChatSummary(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateChatSummary").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatSummary").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) { start := time.Now() r0, r1 := m.s.UpdateChatTitleByID(ctx, arg) @@ -6178,6 +6202,14 @@ func (m queryMetricsStore) UpsertChatRetentionDays(ctx context.Context, retentio return r0 } +func (m queryMetricsStore) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + start := time.Now() + r0 := m.s.UpsertChatSummaryGenerationModelOverride(ctx, value) + m.queryLatencies.WithLabelValues("UpsertChatSummaryGenerationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatSummaryGenerationModelOverride").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertChatSystemPrompt(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e693e675ee1..1ac60455613 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3237,6 +3237,21 @@ func (mr *MockStoreMockRecorder) GetChatStreamSyncRows(ctx, ids any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatStreamSyncRows", reflect.TypeOf((*MockStore)(nil).GetChatStreamSyncRows), ctx, ids) } +// GetChatSummaryGenerationModelOverride mocks base method. +func (m *MockStore) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatSummaryGenerationModelOverride", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatSummaryGenerationModelOverride indicates an expected call of GetChatSummaryGenerationModelOverride. +func (mr *MockStoreMockRecorder) GetChatSummaryGenerationModelOverride(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSummaryGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatSummaryGenerationModelOverride), ctx) +} + // GetChatSystemPrompt mocks base method. func (m *MockStore) GetChatSystemPrompt(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -9710,6 +9725,21 @@ func (mr *MockStoreMockRecorder) UpdateChatMessageByID(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMessageByID", reflect.TypeOf((*MockStore)(nil).UpdateChatMessageByID), ctx, arg) } +// UpdateChatMessageCostSource mocks base method. +func (m *MockStore) UpdateChatMessageCostSource(ctx context.Context, arg database.UpdateChatMessageCostSourceParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatMessageCostSource", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatMessageCostSource indicates an expected call of UpdateChatMessageCostSource. +func (mr *MockStoreMockRecorder) UpdateChatMessageCostSource(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMessageCostSource", reflect.TypeOf((*MockStore)(nil).UpdateChatMessageCostSource), ctx, arg) +} + // UpdateChatModelConfig mocks base method. func (m *MockStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { m.ctrl.T.Helper() @@ -9799,6 +9829,21 @@ func (mr *MockStoreMockRecorder) UpdateChatStatusPreserveUpdatedAt(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatStatusPreserveUpdatedAt", reflect.TypeOf((*MockStore)(nil).UpdateChatStatusPreserveUpdatedAt), ctx, arg) } +// UpdateChatSummary mocks base method. +func (m *MockStore) UpdateChatSummary(ctx context.Context, arg database.UpdateChatSummaryParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChatSummary", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateChatSummary indicates an expected call of UpdateChatSummary. +func (mr *MockStoreMockRecorder) UpdateChatSummary(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatSummary", reflect.TypeOf((*MockStore)(nil).UpdateChatSummary), ctx, arg) +} + // UpdateChatTitleByID mocks base method. func (m *MockStore) UpdateChatTitleByID(ctx context.Context, arg database.UpdateChatTitleByIDParams) (database.Chat, error) { m.ctrl.T.Helper() @@ -11563,6 +11608,20 @@ func (mr *MockStoreMockRecorder) UpsertChatRetentionDays(ctx, retentionDays any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatRetentionDays", reflect.TypeOf((*MockStore)(nil).UpsertChatRetentionDays), ctx, retentionDays) } +// UpsertChatSummaryGenerationModelOverride mocks base method. +func (m *MockStore) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatSummaryGenerationModelOverride", ctx, value) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatSummaryGenerationModelOverride indicates an expected call of UpsertChatSummaryGenerationModelOverride. +func (mr *MockStoreMockRecorder) UpsertChatSummaryGenerationModelOverride(ctx, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSummaryGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatSummaryGenerationModelOverride), ctx, value) +} + // UpsertChatSystemPrompt mocks base method. func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5c2ec2ef0f0..ff996f5d87e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1893,7 +1893,8 @@ CREATE TABLE chat_messages ( deleted boolean DEFAULT false NOT NULL, provider_response_id text, api_key_id text, - revision bigint NOT NULL + revision bigint NOT NULL, + cost_source text ); CREATE SEQUENCE chat_messages_id_seq @@ -2018,6 +2019,8 @@ CREATE TABLE chats ( context_dirty_since timestamp with time zone, context_dirty_resources jsonb, context_error text DEFAULT ''::text NOT NULL, + summary text, + summary_generated_at timestamp with time zone, CONSTRAINT chat_acl_only_on_root_chats CHECK ((((parent_chat_id IS NULL) AND (root_chat_id IS NULL)) OR ((user_acl = '{}'::jsonb) AND (group_acl = '{}'::jsonb)))), CONSTRAINT chat_group_acl_not_null_jsonb CHECK (((group_acl IS NOT NULL) AND (jsonb_typeof(group_acl) = 'object'::text))), CONSTRAINT chat_user_acl_not_null_jsonb CHECK (((user_acl IS NOT NULL) AND (jsonb_typeof(user_acl) = 'object'::text))), @@ -2118,6 +2121,8 @@ CREATE VIEW chats_expanded AS c.plan_mode, c.client_type, c.last_turn_summary, + c.summary, + c.summary_generated_at, c.snapshot_version, c.history_version, c.queue_version, diff --git a/coderd/database/migrations/000530_chat_summary.down.sql b/coderd/database/migrations/000530_chat_summary.down.sql new file mode 100644 index 00000000000..5276d5a17e8 --- /dev/null +++ b/coderd/database/migrations/000530_chat_summary.down.sql @@ -0,0 +1,58 @@ +-- Drop the view before the columns it references, then recreate it without +-- the summary columns, matching the pre-000530 chats_expanded definition. +DROP VIEW IF EXISTS chats_expanded; + +ALTER TABLE chats + DROP COLUMN summary, + DROP COLUMN summary_generated_at; + +ALTER TABLE chat_messages + DROP COLUMN cost_source; + +CREATE VIEW chats_expanded AS + SELECT 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.dynamic_tools, + c.organization_id, + c.plan_mode, + c.client_type, + c.last_turn_summary, + c.snapshot_version, + c.history_version, + c.queue_version, + c.generation_attempt, + c.retry_state, + c.retry_state_version, + c.runner_id, + c.requires_action_deadline_at, + COALESCE(root.user_acl, c.user_acl) AS user_acl, + COALESCE(root.group_acl, c.group_acl) AS group_acl, + owner.username AS owner_username, + owner.name AS owner_name, + c.context_aggregate_hash, + c.context_dirty_since, + c.context_dirty_resources, + c.context_error + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); diff --git a/coderd/database/migrations/000530_chat_summary.up.sql b/coderd/database/migrations/000530_chat_summary.up.sql new file mode 100644 index 00000000000..2d59c0d05bd --- /dev/null +++ b/coderd/database/migrations/000530_chat_summary.up.sql @@ -0,0 +1,69 @@ +-- Add the persisted whole-chat summary and its freshness marker. The summary +-- is generated in the background after successful turns and is distinct from +-- last_turn_summary, which only reflects the most recent turn. +ALTER TABLE chats + ADD COLUMN summary TEXT, + ADD COLUMN summary_generated_at TIMESTAMPTZ; + +-- cost_source attributes spend on a chat_message to a specific feature so that +-- summary and title generation spend can be reported separately from ordinary +-- turn spend. NULL means ordinary turn spend; 'summary' and 'title' tag the +-- hidden accounting rows written for background summary and manual title +-- generation respectively. +ALTER TABLE chat_messages + ADD COLUMN cost_source TEXT; + +-- Recreate chats_expanded so the new chat columns are exposed to the API. The +-- view has an explicit column list, so new columns are otherwise invisible. +-- summary and summary_generated_at are placed next to last_turn_summary. +DROP VIEW IF EXISTS chats_expanded; + +CREATE VIEW chats_expanded AS + SELECT 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.dynamic_tools, + c.organization_id, + c.plan_mode, + c.client_type, + c.last_turn_summary, + c.summary, + c.summary_generated_at, + c.snapshot_version, + c.history_version, + c.queue_version, + c.generation_attempt, + c.retry_state, + c.retry_state_version, + c.runner_id, + c.requires_action_deadline_at, + COALESCE(root.user_acl, c.user_acl) AS user_acl, + COALESCE(root.group_acl, c.group_acl) AS group_acl, + owner.username AS owner_username, + owner.name AS owner_name, + c.context_aggregate_hash, + c.context_dirty_since, + c.context_dirty_resources, + c.context_error + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index e5618d5564e..b385c8d56f8 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -824,6 +824,8 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, &i.Chat.PlanMode, &i.Chat.ClientType, &i.Chat.LastTurnSummary, + &i.Chat.Summary, + &i.Chat.SummaryGeneratedAt, &i.Chat.SnapshotVersion, &i.Chat.HistoryVersion, &i.Chat.QueueVersion, @@ -902,6 +904,8 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, diff --git a/coderd/database/models.go b/coderd/database/models.go index dbba7981c36..bc6dee93fd2 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4792,6 +4792,8 @@ type Chat struct { 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"` + Summary sql.NullString `db:"summary" json:"summary"` + SummaryGeneratedAt sql.NullTime `db:"summary_generated_at" json:"summary_generated_at"` SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` HistoryVersion int64 `db:"history_version" json:"history_version"` QueueVersion int64 `db:"queue_version" json:"queue_version"` @@ -4943,6 +4945,7 @@ type ChatMessage struct { ProviderResponseID sql.NullString `db:"provider_response_id" json:"provider_response_id"` APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` Revision int64 `db:"revision" json:"revision"` + CostSource sql.NullString `db:"cost_source" json:"cost_source"` } type ChatModelConfig struct { @@ -5023,7 +5026,9 @@ type ChatTable struct { // Deterministic prefix of resources that changed since the pinned hash. Reserved for the dirty diff; left NULL until the UI phase populates it. ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` // Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy. - ContextError string `db:"context_error" json:"context_error"` + ContextError string `db:"context_error" json:"context_error"` + Summary sql.NullString `db:"summary" json:"summary"` + SummaryGeneratedAt sql.NullTime `db:"summary_generated_at" json:"summary_generated_at"` } type ChatUsageLimitConfig struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2b1b93b4ac4..1f503868c73 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -462,6 +462,7 @@ type sqlcQuerier interface { // A value of 0 disables chat purging entirely. GetChatRetentionDays(ctx context.Context) (int32, error) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]GetChatStreamSyncRowsRow, error) + GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) GetChatSystemPrompt(ctx context.Context) (string, error) // GetChatSystemPromptConfig returns both chat system prompt settings in a // single read to avoid torn reads between separate site-config lookups. @@ -1350,6 +1351,12 @@ type sqlcQuerier interface { UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) + // Tags a chat_message with a cost_source so its spend is attributable to a + // specific feature (for example 'summary' or 'title') rather than ordinary + // turn spend. Used to mark the hidden accounting rows written for background + // summary and manual title generation without threading a new field through + // the shared InsertChatMessages batch insert. + UpdateChatMessageCostSource(ctx context.Context, arg UpdateChatMessageCostSourceParams) (int64, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) @@ -1358,6 +1365,15 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error) + // Updates the persisted whole-chat summary shown in the chat summary popover. + // Empty or whitespace-only summaries are stored as NULL so callers cannot + // accidentally persist blank text. summary_generated_at records when the + // summary was produced and drives the background regeneration cadence. + // This intentionally preserves updated_at. The staleness guard uses + // history_version, mirroring UpdateChatLastTurnSummary, so background writes + // racing a newer durable history change lose while worker lifecycle + // transitions that do not change message history cannot reject a fresh write. + UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) @@ -1515,6 +1531,7 @@ type sqlcQuerier interface { UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error UpsertChatPlanModeInstructions(ctx context.Context, value string) error UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error + UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5d8d4a600e1..49f1f1d963f 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12474,6 +12474,215 @@ func TestUpdateChatLastTurnSummary(t *testing.T) { require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) } +func TestUpdateChatSummary(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}) + + dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + + modelCfg, err := insertChatModelConfigForTest(ctx, t, db, 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) + require.False(t, chat.Summary.Valid) + require.False(t, chat.SummaryGeneratedAt.Valid) + + // Writing a summary stores it, records summary_generated_at, and does not + // bump updated_at. + affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "Implemented the whole-chat summary feature.", 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: "Implemented the whole-chat summary feature.", Valid: true}, fetched.Summary) + require.True(t, fetched.SummaryGeneratedAt.Valid) + require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) + + // Blank summaries are stored as NULL. + affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: 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.Summary.Valid) + require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) + + // Establish a known-good summary, then advance history_version with a new + // turn so a write carrying the stale history_version is rejected. This is + // how background summary writes racing a newer turn lose. + affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "Fresh whole-chat summary.", Valid: true}, + }) + require.NoError(t, err) + require.EqualValues(t, 1, affected) + + _, err = db.LockChatAndBumpSnapshotVersion(ctx, chat.ID) + require.NoError(t, err) + _, err = db.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedBy: []uuid.UUID{owner.ID}, + ModelConfigID: []uuid.UUID{modelCfg.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleUser}, + Content: []string{`[{"type":"text","text":"new request"}]`}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth}, + InputTokens: []int64{0}, + OutputTokens: []int64{0}, + TotalTokens: []int64{0}, + ReasoningTokens: []int64{0}, + CacheCreationTokens: []int64{0}, + CacheReadTokens: []int64{0}, + ContextLimit: []int64{0}, + Compressed: []bool{false}, + TotalCostMicros: []int64{0}, + RuntimeMs: []int64{0}, + ProviderResponseID: []string{""}, + }) + require.NoError(t, err) + + affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "Stale whole-chat 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 whole-chat summary.", Valid: true}, fetched.Summary) + require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) +} + +func TestUpdateChatMessageCostSource(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}) + + dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + modelCfg, err := insertChatModelConfigForTest(ctx, t, db, 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: "cost-source-chat", + }) + require.NoError(t, err) + + msg := dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + Visibility: database.ChatMessageVisibilityModel, + }) + require.False(t, msg.CostSource.Valid) + + affected, err := db.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ + ID: msg.ID, + CostSource: "summary", + }) + require.NoError(t, err) + require.EqualValues(t, 1, affected) + + fetched, err := db.GetChatMessageByID(ctx, msg.ID) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "summary", Valid: true}, fetched.CostSource) + + // Empty cost source clears the discriminator back to NULL (ordinary spend). + affected, err = db.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ + ID: msg.ID, + CostSource: "", + }) + require.NoError(t, err) + require.EqualValues(t, 1, affected) + + fetched, err = db.GetChatMessageByID(ctx, msg.ID) + require.NoError(t, err) + require.False(t, fetched.CostSource.Valid) +} + func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fd2f86e81fb..c2733da37ff 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5482,7 +5482,7 @@ WHERE LIMIT $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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -5513,6 +5513,8 @@ chats_expanded AS ( acquired_chats.plan_mode, acquired_chats.client_type, acquired_chats.last_turn_summary, + acquired_chats.summary, + acquired_chats.summary_generated_at, acquired_chats.snapshot_version, acquired_chats.history_version, acquired_chats.queue_version, @@ -5534,7 +5536,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id) JOIN visible_users owner ON owner.id = acquired_chats.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -5583,6 +5585,8 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -5737,7 +5741,7 @@ WITH updated_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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -5768,6 +5772,8 @@ chats_expanded AS ( updated_chats.plan_mode, updated_chats.client_type, updated_chats.last_turn_summary, + updated_chats.summary, + updated_chats.summary_generated_at, updated_chats.snapshot_version, updated_chats.history_version, updated_chats.queue_version, @@ -5789,7 +5795,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chats.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -5831,6 +5837,8 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -5897,10 +5905,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.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error + 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.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error, c.summary, c.summary_generated_at ) 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.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error, + 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.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error, a.summary, a.summary_generated_at, -- Children inherit their root's activity so last_activity_at is never null. COALESCE( t.last_activity_at, @@ -5959,6 +5967,8 @@ type AutoArchiveInactiveChatsRow struct { ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` ContextError string `db:"context_error" json:"context_error"` + Summary sql.NullString `db:"summary" json:"summary"` + SummaryGeneratedAt sql.NullTime `db:"summary_generated_at" json:"summary_generated_at"` LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } @@ -6020,6 +6030,8 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.Summary, + &i.SummaryGeneratedAt, &i.LastActivityAt, ); err != nil { return nil, err @@ -6296,7 +6308,7 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds } 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE agent_id = $1::uuid AND archived = false @@ -6344,6 +6356,8 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -6376,7 +6390,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U const getAutoArchiveInactiveChatCandidates = `-- name: GetAutoArchiveInactiveChatCandidates :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.summary, chats_expanded.summary_generated_at, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at FROM chats_expanded LEFT JOIN LATERAL ( @@ -6436,6 +6450,8 @@ type GetAutoArchiveInactiveChatCandidatesRow struct { 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"` + Summary sql.NullString `db:"summary" json:"summary"` + SummaryGeneratedAt sql.NullTime `db:"summary_generated_at" json:"summary_generated_at"` SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` HistoryVersion int64 `db:"history_version" json:"history_version"` QueueVersion int64 `db:"queue_version" json:"queue_version"` @@ -6495,6 +6511,8 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -6549,7 +6567,7 @@ func (q *sqlQuerier) GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatA } 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE id = $1::uuid ` @@ -6585,6 +6603,8 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -6607,7 +6627,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error const getChatByIDForShare = `-- name: GetChatByIDForShare :one WITH shared_chat AS ( - 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at FROM chats WHERE id = $1::uuid FOR SHARE @@ -6641,6 +6661,8 @@ chats_expanded AS ( shared_chat.plan_mode, shared_chat.client_type, shared_chat.last_turn_summary, + shared_chat.summary, + shared_chat.summary_generated_at, shared_chat.snapshot_version, shared_chat.history_version, shared_chat.queue_version, @@ -6662,7 +6684,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id) JOIN visible_users owner ON owner.id = shared_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -6697,6 +6719,8 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -6719,7 +6743,7 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one WITH locked_chat AS ( - 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at FROM chats WHERE id = $1::uuid FOR UPDATE @@ -6753,6 +6777,8 @@ chats_expanded AS ( locked_chat.plan_mode, locked_chat.client_type, locked_chat.last_turn_summary, + locked_chat.summary, + locked_chat.summary_generated_at, locked_chat.snapshot_version, locked_chat.history_version, locked_chat.queue_version, @@ -6774,7 +6800,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id) JOIN visible_users owner ON owner.id = locked_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -6809,6 +6835,8 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -7409,7 +7437,7 @@ func (q *sqlQuerier) GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatP const getChatMessageByID = `-- name: GetChatMessageByID :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -7444,6 +7472,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } @@ -7533,7 +7562,7 @@ func (q *sqlQuerier) GetChatMessageSummariesPerChat(ctx context.Context, created const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -7583,6 +7612,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7599,7 +7629,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes const getChatMessagesByChatIDAscPaginated = `-- name: GetChatMessagesByChatIDAscPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -7652,6 +7682,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7668,7 +7699,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar const getChatMessagesByChatIDDescPaginated = `-- name: GetChatMessagesByChatIDDescPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -7734,6 +7765,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7750,7 +7782,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a const getChatMessagesByRevisionForStream = `-- name: GetChatMessagesByRevisionForStream :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -7799,6 +7831,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7831,7 +7864,7 @@ WITH latest_compressed_summary AS ( 1 ) SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -7905,6 +7938,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -8273,7 +8307,7 @@ func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChat const getChatWorkerAcquisitionCandidates = `-- name: GetChatWorkerAcquisitionCandidates :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.summary, chats_expanded.summary_generated_at, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, chat_heartbeats.heartbeat_at AS current_heartbeat_at, NOT EXISTS ( SELECT 1 @@ -8337,6 +8371,8 @@ type GetChatWorkerAcquisitionCandidatesRow struct { 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"` + Summary sql.NullString `db:"summary" json:"summary"` + SummaryGeneratedAt sql.NullTime `db:"summary_generated_at" json:"summary_generated_at"` SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` HistoryVersion int64 `db:"history_version" json:"history_version"` QueueVersion int64 `db:"queue_version" json:"queue_version"` @@ -8405,6 +8441,8 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -8447,7 +8485,7 @@ WITH cursor_chat AS ( WHERE id = $7 ) SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.summary, chats_expanded.summary_generated_at, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -8686,6 +8724,8 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha &i.Chat.PlanMode, &i.Chat.ClientType, &i.Chat.LastTurnSummary, + &i.Chat.Summary, + &i.Chat.SummaryGeneratedAt, &i.Chat.SnapshotVersion, &i.Chat.HistoryVersion, &i.Chat.QueueVersion, @@ -8719,7 +8759,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha const getChatsByChatFileID = `-- name: GetChatsByChatFileID :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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE @@ -8769,6 +8809,8 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -8800,7 +8842,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) } const getChatsByIDsForRunnerSync = `-- name: GetChatsByIDsForRunnerSync :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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE id = ANY($1::uuid[]) ORDER BY id ASC @@ -8843,6 +8885,8 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -8874,7 +8918,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. } 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -8918,6 +8962,8 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -9018,7 +9064,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.summary, chats_expanded.summary_generated_at, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -9090,6 +9136,8 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC &i.Chat.PlanMode, &i.Chat.ClientType, &i.Chat.LastTurnSummary, + &i.Chat.Summary, + &i.Chat.SummaryGeneratedAt, &i.Chat.SnapshotVersion, &i.Chat.HistoryVersion, &i.Chat.QueueVersion, @@ -9137,7 +9185,7 @@ func (q *sqlQuerier) GetDatabaseNow(ctx context.Context) (time.Time, error) { const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source FROM chat_messages WHERE @@ -9182,13 +9230,14 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE @@ -9248,6 +9297,8 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -9469,7 +9520,7 @@ INSERT INTO chats ( $15::jsonb, $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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -9500,6 +9551,8 @@ chats_expanded AS ( inserted_chat.plan_mode, inserted_chat.client_type, inserted_chat.last_turn_summary, + inserted_chat.summary, + inserted_chat.summary_generated_at, inserted_chat.snapshot_version, inserted_chat.history_version, inserted_chat.queue_version, @@ -9521,7 +9574,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id) JOIN visible_users owner ON owner.id = inserted_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -9592,6 +9645,8 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -9683,7 +9738,7 @@ SELECT NULLIF(UNNEST($18::bigint[]), 0), NULLIF(UNNEST($19::text[]), '') RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source ` type InsertChatMessagesParams struct { @@ -9761,6 +9816,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -10093,7 +10149,7 @@ WITH bumped_chat AS ( WHERE id = $1::uuid FOR UPDATE ) - 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -10124,6 +10180,8 @@ chats_expanded AS ( bumped_chat.plan_mode, bumped_chat.client_type, bumped_chat.last_turn_summary, + bumped_chat.summary, + bumped_chat.summary_generated_at, bumped_chat.snapshot_version, bumped_chat.history_version, bumped_chat.queue_version, @@ -10144,7 +10202,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id) JOIN visible_users owner ON owner.id = bumped_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10183,6 +10241,8 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -10529,7 +10589,7 @@ WITH updated_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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -10560,6 +10620,8 @@ chats_expanded AS ( updated_chats.plan_mode, updated_chats.client_type, updated_chats.last_turn_summary, + updated_chats.summary, + updated_chats.summary_generated_at, updated_chats.snapshot_version, updated_chats.history_version, updated_chats.queue_version, @@ -10581,7 +10643,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chats.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -10627,6 +10689,8 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -10745,7 +10809,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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -10776,6 +10840,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -10797,7 +10863,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10838,6 +10904,8 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -10867,7 +10935,7 @@ SET updated_at = NOW() 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -10898,6 +10966,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -10919,7 +10989,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10959,6 +11029,8 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -10992,7 +11064,7 @@ WITH updated_chat AS ( pin_order = CASE WHEN $2::boolean THEN 0 ELSE pin_order END, updated_at = NOW() 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11023,6 +11095,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -11043,7 +11117,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11100,6 +11174,8 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -11174,7 +11250,7 @@ SET updated_at = NOW() 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11205,6 +11281,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -11226,7 +11304,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11266,6 +11344,8 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -11295,7 +11375,7 @@ SET last_model_config_id = $1::uuid 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11326,6 +11406,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -11347,7 +11429,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11387,6 +11469,8 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -11466,7 +11550,7 @@ SET updated_at = NOW() 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11497,6 +11581,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -11518,7 +11604,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11558,6 +11644,8 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -11587,7 +11675,7 @@ SET WHERE id = $3::bigint RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source ` type UpdateChatMessageByIDParams struct { @@ -11623,10 +11711,38 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } +const updateChatMessageCostSource = `-- name: UpdateChatMessageCostSource :execrows +UPDATE + chat_messages +SET + cost_source = NULLIF($1::text, '') +WHERE + id = $2::bigint +` + +type UpdateChatMessageCostSourceParams struct { + CostSource string `db:"cost_source" json:"cost_source"` + ID int64 `db:"id" json:"id"` +} + +// Tags a chat_message with a cost_source so its spend is attributable to a +// specific feature (for example 'summary' or 'title') rather than ordinary +// turn spend. Used to mark the hidden accounting rows written for background +// summary and manual title generation without threading a new field through +// the shared InsertChatMessages batch insert. +func (q *sqlQuerier) UpdateChatMessageCostSource(ctx context.Context, arg UpdateChatMessageCostSourceParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateChatMessageCostSource, arg.CostSource, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateChatPinOrder = `-- name: UpdateChatPinOrder :exec WITH target_chat AS ( SELECT @@ -11707,7 +11823,7 @@ SET plan_mode = $1::chat_plan_mode 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11738,6 +11854,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -11759,7 +11877,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11799,6 +11917,8 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -11826,7 +11946,7 @@ WITH updated_chat AS ( retry_state = $1::jsonb, updated_at = NOW() 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11857,6 +11977,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -11877,7 +11999,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11919,6 +12041,8 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -11952,7 +12076,7 @@ SET updated_at = NOW() 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -11983,6 +12107,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -12004,7 +12130,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12055,6 +12181,8 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -12088,7 +12216,7 @@ SET updated_at = $6::timestamptz 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -12119,6 +12247,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -12140,7 +12270,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12193,6 +12323,8 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -12213,6 +12345,40 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg return i, err } +const updateChatSummary = `-- name: UpdateChatSummary :execrows +UPDATE chats +SET + summary = NULLIF(REGEXP_REPLACE( + $1::text, '^[[:space:]]+|[[:space:]]+$', '', 'g' + ), ''), + summary_generated_at = NOW() +WHERE + id = $2::uuid + AND history_version = $3::bigint +` + +type UpdateChatSummaryParams struct { + Summary sql.NullString `db:"summary" json:"summary"` + ID uuid.UUID `db:"id" json:"id"` + ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` +} + +// Updates the persisted whole-chat summary shown in the chat summary popover. +// Empty or whitespace-only summaries are stored as NULL so callers cannot +// accidentally persist blank text. summary_generated_at records when the +// summary was produced and drives the background regeneration cadence. +// This intentionally preserves updated_at. The staleness guard uses +// history_version, mirroring UpdateChatLastTurnSummary, so background writes +// racing a newer durable history change lose while worker lifecycle +// transitions that do not change message history cannot reject a fresh write. +func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, arg.ID, arg.ExpectedHistoryVersion) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateChatTitleByID = `-- name: UpdateChatTitleByID :one WITH updated_chat AS ( UPDATE @@ -12224,7 +12390,7 @@ SET title = $1::text 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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -12255,6 +12421,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -12276,7 +12444,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12316,6 +12484,8 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -12344,7 +12514,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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, summary, summary_generated_at ), chats_expanded AS ( SELECT @@ -12375,6 +12545,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -12396,7 +12568,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +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, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, summary, summary_generated_at, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12443,6 +12615,8 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.PlanMode, &i.ClientType, &i.LastTurnSummary, + &i.Summary, + &i.SummaryGeneratedAt, &i.SnapshotVersion, &i.HistoryVersion, &i.QueueVersion, @@ -24359,6 +24533,18 @@ func (q *sqlQuerier) GetChatRetentionDays(ctx context.Context) (int32, error) { return retention_days, err } +const getChatSummaryGenerationModelOverride = `-- name: GetChatSummaryGenerationModelOverride :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_summary_generation_model_override'), '') :: text AS model_config_id +` + +func (q *sqlQuerier) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatSummaryGenerationModelOverride) + var model_config_id string + err := row.Scan(&model_config_id) + return model_config_id, err +} + const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt @@ -24808,6 +24994,16 @@ func (q *sqlQuerier) UpsertChatRetentionDays(ctx context.Context, retentionDays return err } +const upsertChatSummaryGenerationModelOverride = `-- name: UpsertChatSummaryGenerationModelOverride :exec +INSERT INTO site_configs (key, value) VALUES ('agents_chat_summary_generation_model_override', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_summary_generation_model_override' +` + +func (q *sqlQuerier) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatSummaryGenerationModelOverride, value) + return err +} + const upsertChatSystemPrompt = `-- name: UpsertChatSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt' diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index a62ce064389..1a5324cb303 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -34,6 +34,8 @@ chats_expanded AS ( updated_chats.plan_mode, updated_chats.client_type, updated_chats.last_turn_summary, + updated_chats.summary, + updated_chats.summary_generated_at, updated_chats.snapshot_version, updated_chats.history_version, updated_chats.queue_version, @@ -100,6 +102,8 @@ chats_expanded AS ( updated_chats.plan_mode, updated_chats.client_type, updated_chats.last_turn_summary, + updated_chats.summary, + updated_chats.summary_generated_at, updated_chats.snapshot_version, updated_chats.history_version, updated_chats.queue_version, @@ -765,6 +769,8 @@ chats_expanded AS ( inserted_chat.plan_mode, inserted_chat.client_type, inserted_chat.last_turn_summary, + inserted_chat.summary, + inserted_chat.summary_generated_at, inserted_chat.snapshot_version, inserted_chat.history_version, inserted_chat.queue_version, @@ -873,6 +879,19 @@ WHERE RETURNING *; +-- name: UpdateChatMessageCostSource :execrows +-- Tags a chat_message with a cost_source so its spend is attributable to a +-- specific feature (for example 'summary' or 'title') rather than ordinary +-- turn spend. Used to mark the hidden accounting rows written for background +-- summary and manual title generation without threading a new field through +-- the shared InsertChatMessages batch insert. +UPDATE + chat_messages +SET + cost_source = NULLIF(@cost_source::text, '') +WHERE + id = @id::bigint; + -- name: UpdateChatByID :one WITH updated_chat AS ( UPDATE @@ -913,6 +932,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -979,6 +1000,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1043,6 +1066,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1107,6 +1132,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1171,6 +1198,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1234,6 +1263,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1297,6 +1328,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1338,6 +1371,25 @@ WHERE id = @id::uuid AND history_version = @expected_history_version::bigint; +-- name: UpdateChatSummary :execrows +-- Updates the persisted whole-chat summary shown in the chat summary popover. +-- Empty or whitespace-only summaries are stored as NULL so callers cannot +-- accidentally persist blank text. summary_generated_at records when the +-- summary was produced and drives the background regeneration cadence. +-- This intentionally preserves updated_at. The staleness guard uses +-- history_version, mirroring UpdateChatLastTurnSummary, so background writes +-- racing a newer durable history change lose while worker lifecycle +-- transitions that do not change message history cannot reject a fresh write. +UPDATE chats +SET + summary = NULLIF(REGEXP_REPLACE( + sqlc.narg('summary')::text, '^[[:space:]]+|[[:space:]]+$', '', 'g' + ), ''), + summary_generated_at = NOW() +WHERE + id = @id::uuid + AND history_version = @expected_history_version::bigint; + -- name: UpdateChatMCPServerIDs :one WITH updated_chat AS ( UPDATE @@ -1378,6 +1430,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1594,6 +1648,8 @@ chats_expanded AS ( acquired_chats.plan_mode, acquired_chats.client_type, acquired_chats.last_turn_summary, + acquired_chats.summary, + acquired_chats.summary_generated_at, acquired_chats.snapshot_version, acquired_chats.history_version, acquired_chats.queue_version, @@ -1662,6 +1718,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -1730,6 +1788,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -2005,6 +2065,8 @@ chats_expanded AS ( locked_chat.plan_mode, locked_chat.client_type, locked_chat.last_turn_summary, + locked_chat.summary, + locked_chat.summary_generated_at, locked_chat.snapshot_version, locked_chat.history_version, locked_chat.queue_version, @@ -2065,6 +2127,8 @@ chats_expanded AS ( shared_chat.plan_mode, shared_chat.client_type, shared_chat.last_turn_summary, + shared_chat.summary, + shared_chat.summary_generated_at, shared_chat.snapshot_version, shared_chat.history_version, shared_chat.queue_version, @@ -2744,6 +2808,8 @@ chats_expanded AS ( bumped_chat.plan_mode, bumped_chat.client_type, bumped_chat.last_turn_summary, + bumped_chat.summary, + bumped_chat.summary_generated_at, bumped_chat.snapshot_version, bumped_chat.history_version, bumped_chat.queue_version, @@ -2815,6 +2881,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, @@ -2878,6 +2946,8 @@ chats_expanded AS ( updated_chat.plan_mode, updated_chat.client_type, updated_chat.last_turn_summary, + updated_chat.summary, + updated_chat.summary_generated_at, updated_chat.snapshot_version, updated_chat.history_version, updated_chat.queue_version, diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 709cd287ca6..93877b9b514 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -191,6 +191,14 @@ SELECT INSERT INTO site_configs (key, value) VALUES ('agents_chat_title_generation_model_override', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_title_generation_model_override'; +-- name: GetChatSummaryGenerationModelOverride :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_summary_generation_model_override'), '') :: text AS model_config_id; + +-- name: UpsertChatSummaryGenerationModelOverride :exec +INSERT INTO site_configs (key, value) VALUES ('agents_chat_summary_generation_model_override', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_summary_generation_model_override'; + -- name: GetChatDesktopEnabled :one SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index a025e48b5f6..536ae01d8cb 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -621,6 +621,12 @@ func (api *API) chatModelOverrideSiteConfig( getter: api.Database.GetChatTitleGenerationModelOverride, upsert: api.Database.UpsertChatTitleGenerationModelOverride, }, nil + case codersdk.ChatModelOverrideContextSummaryGeneration: + return chatModelOverrideSiteConfig{ + label: "summary generation", + getter: api.Database.GetChatSummaryGenerationModelOverride, + upsert: api.Database.UpsertChatSummaryGenerationModelOverride, + }, nil default: return chatModelOverrideSiteConfig{}, xerrors.Errorf( "unknown chat model override context %q", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 176024a273a..c0823dd6bc6 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -11393,6 +11393,16 @@ func TestChatModelOverrides(t *testing.T) { return db.UpsertChatTitleGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) }, }, + { + name: "SummaryGeneration", + context: codersdk.ChatModelOverrideContextSummaryGeneration, + dbGet: func(ctx context.Context, db database.Store) (string, error) { + return db.GetChatSummaryGenerationModelOverride(dbauthz.AsSystemRestricted(ctx)) + }, + dbUpsert: func(ctx context.Context, db database.Store, value string) error { + return db.UpsertChatSummaryGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) + }, + }, } for _, setting := range settings { diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b82a22289e3..588e79abd0a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2956,6 +2956,12 @@ func recordManualTitleUsage( if len(messages) != 1 { return xerrors.Errorf("expected 1 manual title usage message, got %d", len(messages)) } + if _, err := tx.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ + ID: messages[0].ID, + CostSource: chatCostSourceTitle, + }); err != nil { + return xerrors.Errorf("tag manual title usage cost source: %w", err) + } if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { return xerrors.Errorf("soft delete manual title usage message: %w", err) } @@ -4667,6 +4673,7 @@ func (p *Server) maybeFinalizeTurnStatusLabelAndPush( switch status { case database.ChatStatusWaiting: p.finalizeSuccessfulTurnStatusLabelAndPush(ctx, chat, status, runResult, logger) + p.maybeGenerateChatSummaryAsync(ctx, chat, runResult, logger) case database.ChatStatusPending: p.setLastTurnSummaryAsync(ctx, chat, fallbackTurnStatusLabel(status), logger) @@ -4890,6 +4897,328 @@ func (p *Server) updateLastTurnSummary( p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindSummaryChange, nil) } +const ( + // summaryFirstTurnThreshold is the minimum number of completed turns before + // the first whole-chat summary is generated. + summaryFirstTurnThreshold = 1 + // summaryRefreshTurnThreshold is the number of completed turns since the + // last summary before it is regenerated. It is a tunable cadence knob that + // bounds eager LLM spend. + summaryRefreshTurnThreshold = 3 + // summaryMinTranscriptRunes skips summary generation for very short chats + // where the title already conveys the content. + summaryMinTranscriptRunes = 200 + // chatSummaryWorkTimeout bounds the whole background summary operation + // (load, model resolution, generation, persistence). + chatSummaryWorkTimeout = 120 * time.Second + // chatSummaryGenerateTimeout bounds the model call itself. + chatSummaryGenerateTimeout = 60 * time.Second +) + +// chatCostSource values tag hidden accounting rows so non-turn spend is +// attributable per feature in cost reporting. Ordinary turn spend is left NULL. +const ( + chatCostSourceSummary = "summary" + chatCostSourceTitle = "title" +) + +// maybeGenerateChatSummaryAsync launches background whole-chat summary +// generation after a successful turn on a root chat. It is best-effort: it runs +// detached from the request context so the user's turn is never blocked, and +// logs and swallows errors. +func (p *Server) maybeGenerateChatSummaryAsync( + ctx context.Context, + chat database.Chat, + runResult runChatResult, + logger slog.Logger, +) { + if chat.ParentChatID.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.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, runResult, logger) + }) +} + +// generateAndStoreChatSummary regenerates the whole-chat summary when the +// cadence gate allows, then stores it and records its cost. It is best-effort +// and never clears an existing summary on failure. +func (p *Server) generateAndStoreChatSummary( + ctx context.Context, + chat database.Chat, + runResult runChatResult, + logger slog.Logger, +) { + ctx, cancel := context.WithTimeout(ctx, chatSummaryWorkTimeout) + defer cancel() + + //nolint:gocritic // Narrow daemon access for best-effort summary generation. + authCtx := dbauthz.AsChatd(ctx) + + messages, err := p.db.GetChatMessagesForPromptByChatID(authCtx, chat.ID) + if err != nil { + logger.Debug(ctx, "failed to load messages for chat summary", + slog.F("chat_id", chat.ID), slog.Error(err)) + return + } + + if !shouldGenerateChatSummary(chat, messages) { + return + } + + transcript := renderChatSummaryTranscript(messages) + if len([]rune(transcript)) < summaryMinTranscriptRunes { + logger.Debug(ctx, "skipping chat summary for short transcript", + slog.F("chat_id", chat.ID), + slog.F("transcript_runes", len([]rune(transcript))), + ) + return + } + + model, modelConfig, ok := p.resolveChatSummaryModel(authCtx, chat, runResult, logger) + if !ok { + return + } + + summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) + defer cancelGen() + summary, usage, genErr := generateChatSummary(summaryCtx, model, transcript) + + // Record cost whenever the model reported usage, even on failure, so spend + // is attributed. The active API key is best-effort from the latest turn. + if usage != (fantasy.Usage{}) { + activeAPIKeyID, _ := activeTurnAPIKeyIDFromMessages(messages) + if _, recordErr := recordChatSummaryUsage(authCtx, p.db, chat, modelConfig, usage, activeAPIKeyID); recordErr != nil { + logger.Warn(ctx, "failed to record chat summary usage", + slog.F("chat_id", chat.ID), slog.Error(recordErr)) + } + } + + if genErr != nil { + logger.Debug(ctx, "failed to generate chat summary", + slog.F("chat_id", chat.ID), slog.Error(genErr)) + return + } + + p.updateChatSummary(ctx, chat, chat.HistoryVersion, summary, logger) +} + +// resolveChatSummaryModel resolves the model for summary generation. It prefers +// the deployment summary-generation override when set; a configured-but-unusable +// override is a hard failure that skips generation (preserving any existing +// summary). Otherwise it falls back to the chat's configured model. +func (p *Server) resolveChatSummaryModel( + ctx context.Context, + chat database.Chat, + runResult runChatResult, + logger slog.Logger, +) (fantasy.LanguageModel, database.ChatModelConfig, bool) { + overrideConfig, overrideModel, _, _, overrideSet, overrideErr := p.resolveSummaryGenerationModelOverride( + ctx, chat, runResult.ProviderKeys, runResult.ModelBuildOptions, + ) + if overrideErr != nil { + if overrideSet { + logger.Warn(ctx, "summary generation model override unavailable, skipping summary generation", + slog.F("chat_id", chat.ID), + slog.F("override_context", summaryGenerationOverrideContext), + slog.Error(overrideErr), + ) + return nil, database.ChatModelConfig{}, false + } + logger.Debug(ctx, "failed to resolve summary generation model override", + slog.F("chat_id", chat.ID), + slog.F("override_context", summaryGenerationOverrideContext), + slog.Error(overrideErr), + ) + } + if overrideSet { + return overrideModel, overrideConfig, true + } + + //nolint:dogsled // resolveChatModel returns rich routing metadata; summary generation only needs the model and its config. + model, dbConfig, _, _, _, _, _, err := p.resolveChatModel(ctx, chat, runResult.ModelBuildOptions) + if err != nil { + logger.Debug(ctx, "failed to resolve chat model for summary", + slog.F("chat_id", chat.ID), slog.Error(err)) + return nil, database.ChatModelConfig{}, false + } + return model, dbConfig, true +} + +// shouldGenerateChatSummary applies the cadence gate: generate the first summary +// once enough turns exist, then regenerate every summaryRefreshTurnThreshold +// completed turns since the last summary. +func shouldGenerateChatSummary(chat database.Chat, messages []database.ChatMessage) bool { + if !chat.Summary.Valid { + return countCompletedTurnsSince(messages, time.Time{}) >= summaryFirstTurnThreshold + } + var marker time.Time + if chat.SummaryGeneratedAt.Valid { + marker = chat.SummaryGeneratedAt.Time + } + return countCompletedTurnsSince(messages, marker) >= summaryRefreshTurnThreshold +} + +// countCompletedTurnsSince counts visible user messages created after the given +// time. Each visible user message starts one turn, so this counts turns +// regardless of how many tool-call steps each turn produced. Hidden model-only +// user messages (injected context, the replayed compaction summary) are not +// turns. A zero time counts all turns. +func countCompletedTurnsSince(messages []database.ChatMessage, after time.Time) int { + count := 0 + for _, message := range messages { + if message.Role != database.ChatMessageRoleUser { + continue + } + if message.Visibility != database.ChatMessageVisibilityBoth && + message.Visibility != database.ChatMessageVisibilityUser { + continue + } + if !after.IsZero() && !message.CreatedAt.After(after) { + continue + } + count++ + } + return count +} + +// updateChatSummary writes the persisted whole-chat summary for a chat. Callers +// should pass a detached context because this is a best-effort background write. +// It never clears an existing summary: a blank summary is a no-op. +func (p *Server) updateChatSummary( + ctx context.Context, + chat database.Chat, + expectedHistoryVersion int64, + summary string, + logger slog.Logger, +) { + summary = strings.TrimSpace(summary) + if summary == "" { + return + } + sqlSummary := sql.NullString{String: summary, Valid: true} + + //nolint:gocritic // Narrow daemon access for best-effort summary cache writes. + updateCtx := dbauthz.AsChatd(ctx) + updateCtx, cancel := context.WithTimeout(updateCtx, turnStatusLabelWriteTimeout) + defer cancel() + + affected, err := p.db.UpdateChatSummary(updateCtx, database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: expectedHistoryVersion, + Summary: sqlSummary, + }) + if err != nil { + logger.Warn(updateCtx, "failed to update chat summary", + slog.F("chat_id", chat.ID), slog.Error(err)) + return + } + if affected == 0 { + logger.Info(updateCtx, "skipped stale chat summary update", + slog.F("chat_id", chat.ID), + slog.F("summary_length", len(summary)), + slog.F("expected_history_version", expectedHistoryVersion), + ) + return + } + + updatedChat := chat + updatedChat.Summary = sqlSummary + p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) +} + +// recordChatSummaryUsage records the cost of a background summary generation as +// a hidden, soft-deleted accounting row tagged with cost_source='summary'. It +// mirrors recordManualTitleUsage but does not update the chat title. +func recordChatSummaryUsage( + ctx context.Context, + store database.Store, + chat database.Chat, + modelConfig database.ChatModelConfig, + usage fantasy.Usage, + activeAPIKeyID string, +) (database.Chat, error) { + if usage == (fantasy.Usage{}) { + return chat, nil + } + + callConfig := codersdk.ChatModelCallConfig{} + if len(modelConfig.Options) > 0 { + if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { + return database.Chat{}, xerrors.Errorf("parse model call config: %w", err) + } + } + totalCostMicros := chatcost.CalculateTotalCostMicros( + fantasyUsageToChatMessageUsage(usage), + callConfig.Cost, + ) + + // Use a valid empty JSON array for the content column. + content := "[]" + + updatedChat := chat + err := store.InTx(func(tx database.Store) error { + lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID) + if err != nil { + return xerrors.Errorf("lock chat for summary usage: %w", err) + } + updatedChat = lockedChat + + messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedBy: []uuid.UUID{chat.OwnerID}, + APIKeyID: []string{activeAPIKeyID}, + ModelConfigID: []uuid.UUID{modelConfig.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, + Content: []string{content}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel}, + InputTokens: []int64{usage.InputTokens}, + OutputTokens: []int64{usage.OutputTokens}, + TotalTokens: []int64{usage.TotalTokens}, + ReasoningTokens: []int64{usage.ReasoningTokens}, + CacheCreationTokens: []int64{usage.CacheCreationTokens}, + CacheReadTokens: []int64{usage.CacheReadTokens}, + ContextLimit: []int64{modelConfig.ContextLimit}, + Compressed: []bool{false}, + TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)}, + RuntimeMs: []int64{0}, + ProviderResponseID: []string{""}, + }) + if err != nil { + return xerrors.Errorf("insert summary usage message: %w", err) + } + if len(messages) != 1 { + return xerrors.Errorf("expected 1 summary usage message, got %d", len(messages)) + } + if _, err := tx.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ + ID: messages[0].ID, + CostSource: chatCostSourceSummary, + }); err != nil { + return xerrors.Errorf("tag summary usage cost source: %w", err) + } + if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { + return xerrors.Errorf("soft delete summary usage message: %w", err) + } + if lockedChat.LastModelConfigID != modelConfig.ID { + if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{ + ID: chat.ID, + LastModelConfigID: lockedChat.LastModelConfigID, + }); err != nil { + return xerrors.Errorf("restore chat model config after summary usage: %w", err) + } + } + return nil + }, nil) + if err != nil { + return database.Chat{}, err + } + return updatedChat, nil +} + func (p *Server) webpushConfigured() bool { return p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != "" } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index bafde28c610..cb56dc1212a 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -930,6 +930,10 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { return []database.ChatMessage{{ID: 91}}, nil }, ) + usageTx.EXPECT().UpdateChatMessageCostSource(gomock.Any(), database.UpdateChatMessageCostSourceParams{ + ID: 91, + CostSource: "title", + }).Return(int64(1), nil) usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ ID: chatID, @@ -1110,6 +1114,10 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t return []database.ChatMessage{{ID: 91}}, nil }, ) + usageTx.EXPECT().UpdateChatMessageCostSource(gomock.Any(), database.UpdateChatMessageCostSourceParams{ + ID: 91, + CostSource: "title", + }).Return(int64(1), nil) usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ ID: chatID, diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 88642d67de4..1414882c05c 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -872,6 +872,216 @@ func generateManualTitle( return title, usage, nil } +const chatSummaryGenerationPrompt = "You summarize an AI coding chat for a quick-reference popover. " + + "Populate the summary field with 1 to 3 plain sentences describing what the conversation is about and what was accomplished or attempted. " + + "Write about the conversation in the third person. " + + "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " + + "Do not address the user, give instructions, or continue the task. " + + "No markdown, lists, headings, code fences, or surrounding quotes." + +const ( + // summaryTranscriptMaxRunes bounds the rendered conversation passed to the + // summary model so the call stays cheap and within the context window. + // Long chats are bounded by keeping head and tail turns (see + // renderChatSummaryTranscript). + summaryTranscriptMaxRunes = 16000 + // summaryTranscriptPerMessageMaxRunes caps any single rendered turn so one + // very long message (such as a replayed compaction summary) cannot dominate + // the transcript budget. + summaryTranscriptPerMessageMaxRunes = 4000 + // summaryMaxOutputTokens bounds the generated summary length. Combined with + // the prompt, this keeps the summary to a short blurb. + summaryMaxOutputTokens = 512 + // summaryMaxRunes rejects pathologically long summaries that ignore the + // length instruction. + summaryMaxRunes = 1000 + // summaryMaxSentences rejects pathologically verbose summaries while leaving + // slack over the 1-3 sentence target so a slightly long but useful summary + // is still stored. + summaryMaxSentences = 6 +) + +type generatedChatSummary struct { + Summary string `json:"summary" description:"1-3 sentence summary of the whole chat"` +} + +// renderChatSummaryTranscript renders compaction-aware chat history into a +// plain-text transcript for whole-chat summary generation. It includes user and +// assistant text, intentionally keeping the replayed compaction summary (a +// model-only compressed message) so the summary still covers pre-compaction +// content, and skips the system prompt and tool-call/tool-result noise. +// +// The history is rendered as plain transcript text rather than replayed as raw +// fantasy messages so that provider tool-call pairing rules do not apply to +// historical tool messages during structured (object.Generate) generation. +func renderChatSummaryTranscript(messages []database.ChatMessage) string { + lines := make([]string, 0, len(messages)) + for _, message := range messages { + var role string + switch message.Role { + case database.ChatMessageRoleUser: + role = "user" + case database.ChatMessageRoleAssistant: + role = "assistant" + default: + continue + } + + // Keep visible turns plus the replayed compaction summary, which is a + // model-only compressed message. Other model-only messages (such as + // injected context) are skipped as noise. + visible := message.Visibility == database.ChatMessageVisibilityBoth || + message.Visibility == database.ChatMessageVisibilityUser + compactionSummary := message.Visibility == database.ChatMessageVisibilityModel && + message.Compressed + if !visible && !compactionSummary { + continue + } + + parts, err := chatprompt.ParseContent(message) + if err != nil { + continue + } + text := strings.TrimSpace(contentBlocksToText(parts)) + if text == "" { + continue + } + text = truncateRunes(text, summaryTranscriptPerMessageMaxRunes) + lines = append(lines, fmt.Sprintf("[%s]: %s", role, text)) + } + return boundTranscriptHeadTail(lines, summaryTranscriptMaxRunes) +} + +// boundTranscriptHeadTail joins lines, and if the result exceeds maxRunes keeps +// a head and tail slice within budget with an elision marker in between. This +// keeps very long chats bounded while preserving both the start (what the chat +// is about) and the end (what was most recently accomplished). +func boundTranscriptHeadTail(lines []string, maxRunes int) string { + if len(lines) == 0 { + return "" + } + total := 0 + for _, line := range lines { + total += len([]rune(line)) + 1 + } + if total <= maxRunes { + return strings.Join(lines, "\n") + } + + half := maxRunes / 2 + headEnd := 0 + headRunes := 0 + for headEnd < len(lines) { + n := len([]rune(lines[headEnd])) + 1 + if headEnd > 0 && headRunes+n > half { + break + } + headRunes += n + headEnd++ + } + tailStart := len(lines) + tailRunes := 0 + for tailStart > headEnd { + n := len([]rune(lines[tailStart-1])) + 1 + if tailRunes+n > half { + break + } + tailRunes += n + tailStart-- + } + + out := make([]string, 0, headEnd+(len(lines)-tailStart)+1) + out = append(out, lines[:headEnd]...) + if tailStart > headEnd { + out = append(out, "[... earlier turns omitted ...]") + } + out = append(out, lines[tailStart:]...) + return strings.Join(out, "\n") +} + +// generateChatSummary generates a 1-3 sentence whole-chat summary from a +// rendered transcript using structured output. It returns the model usage so +// callers can record cost. A blank or invalid result returns an error so +// callers preserve any existing summary rather than clearing it. +func generateChatSummary( + ctx context.Context, + model fantasy.LanguageModel, + transcript string, +) (string, fantasy.Usage, error) { + transcript = strings.TrimSpace(transcript) + if transcript == "" { + return "", fantasy.Usage{}, xerrors.New("chat summary transcript was empty") + } + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: chatSummaryGenerationPrompt}, + }, + }, + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: transcript}, + }, + }, + } + + maxOutputTokens := int64(summaryMaxOutputTokens) + var result *fantasy.ObjectResult[generatedChatSummary] + err := chatretry.Retry(ctx, func(retryCtx context.Context) error { + var genErr error + result, genErr = object.Generate[generatedChatSummary](retryCtx, model, fantasy.ObjectCall{ + Prompt: prompt, + SchemaName: "chat_summary", + SchemaDescription: "Summarize the whole chat in 1-3 sentences.", + MaxOutputTokens: &maxOutputTokens, + }) + return genErr + }, nil) + if err != nil { + var usage fantasy.Usage + var noObjErr *fantasy.NoObjectGeneratedError + if errors.As(err, &noObjErr) { + usage = noObjErr.Usage + } + return "", usage, xerrors.Errorf("generate chat summary: %w", err) + } + + summary := normalizeShortTextOutput(result.Object.Summary) + if err := validateGeneratedChatSummary(summary); err != nil { + return "", result.Usage, err + } + return summary, result.Usage, nil +} + +func validateGeneratedChatSummary(summary string) error { + if summary == "" { + return xerrors.New("generated chat summary was empty") + } + if len([]rune(summary)) > summaryMaxRunes { + return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes) + } + if countSentenceTerminators(summary) > summaryMaxSentences { + return xerrors.Errorf("generated chat summary exceeded %d sentences", summaryMaxSentences) + } + return nil +} + +// countSentenceTerminators counts sentence-ending punctuation. It is an +// approximation used only as a safety net against pathologically verbose +// output, so abbreviations inflating the count slightly is acceptable. +func countSentenceTerminators(text string) int { + count := 0 + for _, r := range text { + if r == '.' || r == '!' || r == '?' { + count++ + } + } + return count +} + const turnStatusLabelPrompt = "You write compact chat status labels for a sidebar or push notification. " + "Given a chat title, current chat state, and the agent's latest message, populate the label field with a 2-5 word status label. " + "Describe the chat's current state, not the agent. " + diff --git a/coderd/x/chatd/summary_override.go b/coderd/x/chatd/summary_override.go new file mode 100644 index 00000000000..7403947bb71 --- /dev/null +++ b/coderd/x/chatd/summary_override.go @@ -0,0 +1,102 @@ +package chatd + +import ( + "context" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" +) + +const summaryGenerationOverrideContext = "summary_generation" + +func readSummaryGenerationModelOverride( + ctx context.Context, + db database.Store, +) (string, error) { + //nolint:gocritic // Chatd is internal, not a user, so this read uses AsChatd. + chatdCtx := dbauthz.AsChatd(ctx) + raw, err := db.GetChatSummaryGenerationModelOverride(chatdCtx) + if err != nil { + return "", xerrors.Errorf( + "get chat summary generation model override: %w", + err, + ) + } + return raw, nil +} + +// resolveSummaryGenerationModelOverride resolves the deployment-wide summary +// generation model override. overrideSet is true when an override was +// configured; in that case any returned error is a hard failure and the caller +// should skip summary generation. When overrideSet is false, callers fall back +// to the chat's configured model. +func (p *Server) resolveSummaryGenerationModelOverride( + ctx context.Context, + chat database.Chat, + keys chatprovider.ProviderAPIKeys, + modelOpts modelBuildOptions, +) (database.ChatModelConfig, fantasy.LanguageModel, chatprovider.ProviderAPIKeys, resolvedModelRoute, bool, error) { + raw, err := readSummaryGenerationModelOverride(ctx, p.db) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, false, xerrors.Errorf( + "read summary generation model override: %w", + err, + ) + } + + overrideProviderKeys := keys + modelConfig, overrideSet, err := p.resolveConfiguredModelOverride( + ctx, + summaryGenerationOverrideContext, + raw, + chat.OwnerID, + p.resolveModelConfigAndNormalizedProvider, + func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { + if aiProviderID == uuid.Nil { + resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, uuid.Nil) + if err != nil || resolvedProviderKeys.Empty() { + resolvedProviderKeys = keys + } + overrideProviderKeys = resolvedProviderKeys + return resolvedProviderKeys, nil + } + resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) + if err != nil { + return chatprovider.ProviderAPIKeys{}, err + } + overrideProviderKeys = resolvedProviderKeys + return resolvedProviderKeys, nil + }, + modelOverrideFailureModeHard, + ) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, overrideSet, err + } + if !overrideSet { + return database.ChatModelConfig{}, nil, keys, resolvedModelRoute{}, false, nil + } + + //nolint:gocritic // Summary overrides need chatd-scoped provider reads for user-owned chats. + route, err := p.resolveModelRouteForConfig(dbauthz.AsChatd(ctx), chat.OwnerID, modelConfig, overrideProviderKeys) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, err + } + model, err := p.newModel(ctx, modelClientRequest{ + Chat: chat, + ModelName: modelConfig.Model, + UserAgent: chatprovider.UserAgent(), + ExtraHeaders: chatprovider.CoderHeaders(chat), + }, route, modelOpts) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, xerrors.Errorf( + "create summary generation model override: %w", + err, + ) + } + return modelConfig, model, route.directProviderKeys(), route, true, nil +} diff --git a/coderd/x/chatd/summary_override_internal_test.go b/coderd/x/chatd/summary_override_internal_test.go new file mode 100644 index 00000000000..557b538c957 --- /dev/null +++ b/coderd/x/chatd/summary_override_internal_test.go @@ -0,0 +1,120 @@ +package chatd + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/testutil" +) + +func TestResolveSummaryGenerationModelOverride_Unset(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("", nil) + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + require.NoError(t, err) + require.False(t, overrideSet) + require.Nil(t, model) + require.Equal(t, database.ChatModelConfig{}, config) +} + +func TestResolveSummaryGenerationModelOverride_MalformedFallsThrough(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("not-a-uuid", nil) + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + require.NoError(t, err) + require.False(t, overrideSet) + require.Nil(t, model) + require.Equal(t, database.ChatModelConfig{}, config) +} + +func TestResolveSummaryGenerationModelOverride_SetUsable(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + overrideConfig := titleOverrideModelConfig("gpt-4.1", true) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{Type: database.AIProviderTypeOpenai, Enabled: true}}, nil) + db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + require.NoError(t, err) + require.True(t, overrideSet) + require.NotNil(t, model) + require.Equal(t, overrideConfig, config) +} + +func TestResolveSummaryGenerationModelOverride_SetUnusableHardFails(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + // A disabled config is treated as unavailable. + overrideConfig := titleOverrideModelConfig("gpt-4.1", false) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + // overrideSet is true even on a hard failure so the caller skips generation + // instead of falling back to the chat model. + require.Error(t, err) + require.True(t, overrideSet) + require.ErrorContains(t, err, "summary generation model override is unavailable") + require.Nil(t, model) + require.Equal(t, database.ChatModelConfig{}, config) +} diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go new file mode 100644 index 00000000000..e93156ec0cd --- /dev/null +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -0,0 +1,194 @@ +package chatd + +import ( + "database/sql" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/codersdk" +) + +func summaryTestMessage( + t *testing.T, + id int64, + role database.ChatMessageRole, + visibility database.ChatMessageVisibility, + parts []codersdk.ChatMessagePart, + compressed bool, + createdAt time.Time, +) database.ChatMessage { + t.Helper() + content, err := chatprompt.MarshalParts(parts) + require.NoError(t, err) + return database.ChatMessage{ + ID: id, + Role: role, + Visibility: visibility, + Content: content, + ContentVersion: chatprompt.CurrentContentVersion, + Compressed: compressed, + CreatedAt: createdAt, + } +} + +func summaryTextMessage( + t *testing.T, + id int64, + role database.ChatMessageRole, + visibility database.ChatMessageVisibility, + text string, + compressed bool, + createdAt time.Time, +) database.ChatMessage { + t.Helper() + return summaryTestMessage(t, id, role, visibility, + []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)}, + compressed, createdAt) +} + +func TestRenderChatSummaryTranscript(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC) + messages := []database.ChatMessage{ + // System prompt is skipped as boilerplate. + summaryTextMessage(t, 1, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "you are a helpful agent", false, base), + // Replayed compaction summary: model-only but compressed, kept. + summaryTextMessage(t, 2, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "earlier work compaction summary", true, base.Add(time.Minute)), + // Injected context: model-only and not compressed, skipped as noise. + summaryTextMessage(t, 3, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "AGENTS.md injected context", false, base.Add(2*time.Minute)), + // Normal visible turn. + summaryTextMessage(t, 4, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "fix the bug in foo.go", false, base.Add(3*time.Minute)), + // Assistant tool-call only message (no text) is skipped. + summaryTestMessage(t, 5, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, + []codersdk.ChatMessagePart{codersdk.ChatMessageToolCall("call-1", "bash", []byte(`{"cmd":"go test"}`))}, + false, base.Add(4*time.Minute)), + // Tool result is skipped. + summaryTextMessage(t, 6, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "tests passed", false, base.Add(5*time.Minute)), + // Assistant final text is kept. + summaryTextMessage(t, 7, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "fixed the bug and added a test", false, base.Add(6*time.Minute)), + } + + transcript := renderChatSummaryTranscript(messages) + + require.Contains(t, transcript, "earlier work compaction summary") + require.Contains(t, transcript, "[user]: fix the bug in foo.go") + require.Contains(t, transcript, "[assistant]: fixed the bug and added a test") + // System prompt, injected context, tool-call, and tool result are excluded. + require.NotContains(t, transcript, "you are a helpful agent") + require.NotContains(t, transcript, "AGENTS.md injected context") + require.NotContains(t, transcript, "tests passed") + require.NotContains(t, transcript, "go test") +} + +func TestBoundTranscriptHeadTail(t *testing.T) { + t.Parallel() + + t.Run("UnderBudgetReturnsAll", func(t *testing.T) { + t.Parallel() + lines := []string{"a", "b", "c"} + require.Equal(t, "a\nb\nc", boundTranscriptHeadTail(lines, 1000)) + }) + + t.Run("OverBudgetKeepsHeadAndTail", func(t *testing.T) { + t.Parallel() + lines := []string{ + "HEAD-FIRST " + strings.Repeat("x", 40), + strings.Repeat("m", 200), + strings.Repeat("n", 200), + strings.Repeat("o", 200), + "TAIL-LAST " + strings.Repeat("y", 40), + } + out := boundTranscriptHeadTail(lines, 160) + require.Contains(t, out, "HEAD-FIRST") + require.Contains(t, out, "TAIL-LAST") + require.Contains(t, out, "[... earlier turns omitted ...]") + }) +} + +func TestShouldGenerateChatSummary(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC) + userMsg := func(id int64, at time.Time) database.ChatMessage { + return database.ChatMessage{ + ID: id, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityBoth, + CreatedAt: at, + } + } + assistantMsg := func(id int64, at time.Time) database.ChatMessage { + return database.ChatMessage{ + ID: id, + Role: database.ChatMessageRoleAssistant, + Visibility: database.ChatMessageVisibilityBoth, + CreatedAt: at, + } + } + + t.Run("FirstSummaryAtFirstTurn", func(t *testing.T) { + t.Parallel() + chat := database.Chat{} + msgs := []database.ChatMessage{ + userMsg(1, base), + assistantMsg(2, base.Add(time.Minute)), + } + require.True(t, shouldGenerateChatSummary(chat, msgs)) + }) + + t.Run("FirstSummarySkippedWithNoTurns", func(t *testing.T) { + t.Parallel() + chat := database.Chat{} + require.False(t, shouldGenerateChatSummary(chat, nil)) + }) + + t.Run("MultiStepTurnDoesNotInflateCount", func(t *testing.T) { + t.Parallel() + marker := base + chat := database.Chat{ + Summary: sql.NullString{String: "existing", Valid: true}, + SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true}, + } + // One user turn after the marker, but many assistant messages (tool + // steps). Counting user turns keeps this below the refresh threshold. + msgs := []database.ChatMessage{ + userMsg(1, marker.Add(time.Minute)), + assistantMsg(2, marker.Add(2*time.Minute)), + assistantMsg(3, marker.Add(3*time.Minute)), + assistantMsg(4, marker.Add(4*time.Minute)), + } + require.False(t, shouldGenerateChatSummary(chat, msgs)) + }) + + t.Run("RefreshAfterThresholdTurns", func(t *testing.T) { + t.Parallel() + marker := base + chat := database.Chat{ + Summary: sql.NullString{String: "existing", Valid: true}, + SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true}, + } + msgs := []database.ChatMessage{ + // Pre-marker turn is not counted. + userMsg(1, marker.Add(-time.Minute)), + userMsg(2, marker.Add(time.Minute)), + userMsg(3, marker.Add(2*time.Minute)), + userMsg(4, marker.Add(3*time.Minute)), + } + require.True(t, shouldGenerateChatSummary(chat, msgs)) + }) +} + +func TestValidateGeneratedChatSummary(t *testing.T) { + t.Parallel() + + require.Error(t, validateGeneratedChatSummary("")) + require.Error(t, validateGeneratedChatSummary(strings.Repeat("a", summaryMaxRunes+1))) + require.Error(t, validateGeneratedChatSummary("One. Two. Three. Four. Five. Six. Seven.")) + require.NoError(t, validateGeneratedChatSummary("Implemented the summary feature. Added tests.")) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index eadeec97efd..accee885731 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -107,26 +107,30 @@ const ( // Chat represents a chat session with an AI agent. type Chat struct { - ID uuid.UUID `json:"id" format:"uuid"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OwnerID uuid.UUID `json:"owner_id" format:"uuid"` - OwnerUsername string `json:"owner_username,omitempty"` - OwnerName string `json:"owner_name,omitempty"` - WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` - BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"` - AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` - ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` - RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` - LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` - Title string `json:"title"` - 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"` - Archived bool `json:"archived"` + ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + OwnerUsername string `json:"owner_username,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` + BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"` + AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` + ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` + RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` + LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` + Title string `json:"title"` + Status ChatStatus `json:"status"` + PlanMode ChatPlanMode `json:"plan_mode,omitempty"` + LastError *ChatError `json:"last_error,omitempty"` + LastTurnSummary *string `json:"last_turn_summary"` + // Summary is the persisted whole-chat summary shown in the chat summary + // popover. It is generated asynchronously in the background and may be nil + // until the first summary has been produced. + Summary *string `json:"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"` + Archived bool `json:"archived"` // Shared is true when this chat's root chat has explicit user or group ACL entries. Shared bool `json:"shared"` PinOrder int32 `json:"pin_order"` @@ -735,9 +739,10 @@ type UpdateChatPlanModeInstructionsRequest struct { type ChatModelOverrideContext string const ( - ChatModelOverrideContextGeneral ChatModelOverrideContext = "general" - ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" - ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" + ChatModelOverrideContextGeneral ChatModelOverrideContext = "general" + ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" + ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" + ChatModelOverrideContextSummaryGeneration ChatModelOverrideContext = "summary_generation" ) // Valid reports whether the override context is one of the supported values. @@ -745,7 +750,8 @@ func (c ChatModelOverrideContext) Valid() bool { switch c { case ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, - ChatModelOverrideContextTitleGeneration: + ChatModelOverrideContextTitleGeneration, + ChatModelOverrideContextSummaryGeneration: return true default: return false @@ -758,6 +764,7 @@ func AllChatModelOverrideContexts() []ChatModelOverrideContext { ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, ChatModelOverrideContextTitleGeneration, + ChatModelOverrideContextSummaryGeneration, } } @@ -1763,13 +1770,18 @@ func NewDynamicTool[T any]( type ChatWatchEventKind string const ( - ChatWatchEventKindStatusChange ChatWatchEventKind = "status_change" - ChatWatchEventKindSummaryChange ChatWatchEventKind = "summary_change" - ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" - ChatWatchEventKindCreated ChatWatchEventKind = "created" - ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" - ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" - ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" + ChatWatchEventKindStatusChange ChatWatchEventKind = "status_change" + ChatWatchEventKindSummaryChange ChatWatchEventKind = "summary_change" + // ChatWatchEventKindChatSummaryChange delivers updates to the persisted + // whole-chat summary field. It is distinct from SummaryChange, which is + // bound to last_turn_summary, so the frontend can apply only the summary + // field without disturbing last_turn_summary. + ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" + ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" + ChatWatchEventKindCreated ChatWatchEventKind = "created" + ChatWatchEventKindDeleted ChatWatchEventKind = "deleted" + ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" + ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" // ChatWatchEventKindContextDirty signals that the chat's pinned // workspace context drifted from the agent's latest pushed snapshot. // The chat stays usable; a refresh re-pins it to the latest snapshot. diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index c30b7d94131..083af89636c 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -13,41 +13,41 @@ We track the following resources: -| Resource | | | -|-----------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| -| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| -| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| -| AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| -| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| -| 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
| -| AuditableGroupAIBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| -| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| -| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
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
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| -| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| -| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| -| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| -| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| -| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
default_org_member_rolestrue
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| -| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| -| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| -| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| -| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| -| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| -| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| -| User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| -| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| -| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| -| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| +| Resource | | | +|-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| +| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| +| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| +| AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| +| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| +| 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
| +| AuditableGroupAIBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| +| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
summaryfalse
summary_generated_atfalse
titletrue
updated_atfalse
user_acltrue
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
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| +| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| +| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| +| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| +| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| +| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| +| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| +| Organization
| |
FieldTracked
created_atfalse
default_org_member_rolestrue
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| +| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| +| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| +| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| +| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| +| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| +| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| +| User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| +| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| +| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 2f215c50ce1..eb1c7e86c75 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -121,6 +121,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -216,6 +217,7 @@ Status Code **200** | `» root_chat_id` | string(uuid) | false | | | | `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `» summary` | string | false | | Summary is the persisted whole-chat summary shown in the chat summary popover. It is generated asynchronously in the background and may be nil until the first summary has been produced. | | `» title` | string | false | | | | `» updated_at` | string(date-time) | false | | | | `» warnings` | array | false | | | @@ -393,6 +395,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -485,6 +488,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -728,6 +732,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -874,6 +879,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -966,6 +972,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -1149,6 +1156,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -1241,6 +1249,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -1422,6 +1431,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -1514,6 +1524,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -2262,6 +2273,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -2354,6 +2366,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -2860,6 +2873,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -2952,6 +2966,7 @@ Experimental: this endpoint is subject to change. "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index af90fe3da33..1382d9d0e8c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2095,6 +2095,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -2187,6 +2188,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -2227,6 +2229,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `root_chat_id` | string | false | | | | `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | +| `summary` | string | false | | Summary is the persisted whole-chat summary shown in the chat summary popover. It is generated asynchronously in the background and may be nil until the first summary has been produced. | | `title` | string | false | | | | `updated_at` | string | false | | | | `warnings` | array of string | false | | | @@ -3947,6 +3950,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", "shared": true, "status": "waiting", + "summary": "string", "title": "string", "updated_at": "2019-08-24T14:15:22Z", "warnings": [ @@ -3983,9 +3987,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|-----------------------------------------------------------------------------------------------------------------------------------| -| `action_required`, `context_dirty`, `created`, `deleted`, `diff_status_change`, `status_change`, `summary_change`, `title_change` | +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action_required`, `chat_summary_change`, `context_dirty`, `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 e412a2c2eb8..e709063b80c 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -459,6 +459,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "archived": ActionTrack, "last_error": ActionIgnore, // Internal. "last_turn_summary": ActionIgnore, // Internal cached display text. + "summary": ActionIgnore, // Internal cached display text, generated asynchronously. + "summary_generated_at": ActionIgnore, // Internal freshness marker for the cached summary. "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 52cd88f8d4b..9bd6c583ce7 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -127,6 +127,7 @@ const makeChat = ( has_unread: false, client_type: "ui", last_turn_summary: null, + summary: null, children: [], ...overrides, }); @@ -2364,6 +2365,29 @@ describe("mergeWatchedChatSummary", () => { ).toBe("Fixed the issue"); }); + it("applies chat_summary_change even when event updated_at is older", () => { + const cachedChat = makeChat("chat-1", { + summary: null, + last_turn_summary: "Latest turn", + updated_at: "2025-01-01T00:05:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + summary: "Implemented the whole-chat summary feature.", + // chat_summary_change preserves updated_at, so the event carries an + // equal-or-older timestamp than the cached chat. + last_turn_summary: "Stale turn", + updated_at: "2025-01-01T00:00:00.000Z", + }); + + const merged = mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "chat_summary_change", + }); + expect(merged.summary).toBe("Implemented the whole-chat summary feature."); + // A chat_summary_change event must not clobber last_turn_summary with the + // event's stale snapshot. + expect(merged.last_turn_summary).toBe("Latest turn"); + }); + it("clears last_turn_summary on summary updates with matching updated_at", () => { const cachedChat = makeChat("chat-1", { last_turn_summary: "Previous summary", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index bd4e2ae0994..8730746089b 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -420,6 +420,7 @@ export const mergeWatchedChatSummary = ( const isTitleEvent = eventKind === "title_change"; const isStatusEvent = eventKind === "status_change"; const isSummaryEvent = eventKind === "summary_change"; + const isChatSummaryEvent = eventKind === "chat_summary_change"; const isDiffStatusEvent = eventKind === "diff_status_change"; const isContextDirtyEvent = eventKind === "context_dirty"; const updatedAtComparison = compareUpdatedAtInstants( @@ -460,6 +461,14 @@ export const mergeWatchedChatSummary = ( isFreshEnough || isSummaryEvent ? watchedChat.last_turn_summary : cachedChat.last_turn_summary; + // The whole-chat summary is delivered via its own chat_summary_change event + // and preserves updated_at, so apply it even when the cached timestamp is + // newer. A distinct event ensures it only touches summary, not + // last_turn_summary. + const nextSummary = + isFreshEnough || isChatSummaryEvent + ? watchedChat.summary + : cachedChat.summary; const nextHasUnread = isFreshEnough && isStatusEvent && watchedChat.id !== activeChatId ? true @@ -478,6 +487,7 @@ export const mergeWatchedChatSummary = ( nextBuildId === cachedChat.build_id && nextLastModelConfigId === cachedChat.last_model_config_id && nextLastTurnSummary === cachedChat.last_turn_summary && + nextSummary === cachedChat.summary && nextHasUnread === cachedChat.has_unread && nextUpdatedAt === cachedChat.updated_at && nextContext === cachedChat.context @@ -494,6 +504,7 @@ export const mergeWatchedChatSummary = ( build_id: nextBuildId, last_model_config_id: nextLastModelConfigId, last_turn_summary: nextLastTurnSummary, + summary: nextSummary, has_unread: nextHasUnread, updated_at: nextUpdatedAt, context: nextContext, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9301a9eb793..1b943d7abfe 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1551,6 +1551,12 @@ export interface Chat { readonly plan_mode?: ChatPlanMode; readonly last_error?: ChatError; readonly last_turn_summary: string | null; + /** + * Summary is the persisted whole-chat summary shown in the chat summary + * popover. It is generated asynchronously in the background and may be nil + * until the first summary has been produced. + */ + readonly summary: string | null; readonly diff_status?: ChatDiffStatus; readonly created_at: string; readonly updated_at: string; @@ -2589,11 +2595,13 @@ export interface ChatModelOpenRouterProviderOptions { export type ChatModelOverrideContext = | "explore" | "general" + | "summary_generation" | "title_generation"; export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [ "explore", "general", + "summary_generation", "title_generation", ]; @@ -3228,6 +3236,7 @@ export interface ChatWatchEvent { // From codersdk/chats.go export type ChatWatchEventKind = | "action_required" + | "chat_summary_change" | "context_dirty" | "created" | "deleted" @@ -3238,6 +3247,7 @@ export type ChatWatchEventKind = export const ChatWatchEventKinds: ChatWatchEventKind[] = [ "action_required", + "chat_summary_change", "context_dirty", "created", "deleted", diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 86bf90598c9..aa7a7aced7d 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -143,6 +143,7 @@ const baseChatFields = { has_unread: false, client_type: "ui", last_turn_summary: null, + summary: null, children: [], } as const; diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx index 5f664afb150..ffe681299d8 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx @@ -20,6 +20,8 @@ const generalOverrideContext: TypesGen.ChatModelOverrideContext = "general"; const exploreOverrideContext: TypesGen.ChatModelOverrideContext = "explore"; const titleGenerationOverrideContext: TypesGen.ChatModelOverrideContext = "title_generation"; +const summaryGenerationOverrideContext: TypesGen.ChatModelOverrideContext = + "summary_generation"; const chatModelOverrideKey = (context: TypesGen.ChatModelOverrideContext) => ["chat-model-override", context] as const; @@ -66,6 +68,10 @@ const AgentSettingsAgentsPage: FC = () => { ...chatModelOverrideQuery(titleGenerationOverrideContext), enabled: canEditDeploymentConfig, }); + const summaryGenerationModelQuery = useQuery({ + ...chatModelOverrideQuery(summaryGenerationOverrideContext), + enabled: canEditDeploymentConfig, + }); const modelConfigsQuery = useQuery(chatModelConfigs()); const savePersonalModelOverridesAdminSettingsMutation = useMutation( updateChatPersonalModelOverridesAdminSettings(queryClient), @@ -79,6 +85,12 @@ const AgentSettingsAgentsPage: FC = () => { titleGenerationOverrideContext, ), ); + const saveSummaryGenerationModelMutation = useMutation( + updateChatModelOverrideMutation( + queryClient, + summaryGenerationOverrideContext, + ), + ); const saveExploreModelOverrideMutation = useMutation( updateChatModelOverrideMutation(queryClient, exploreOverrideContext), ); @@ -123,6 +135,14 @@ const AgentSettingsAgentsPage: FC = () => { isSaveTitleGenerationModelError={ saveTitleGenerationModelMutation.isError } + summaryGenerationModelOverrideData={summaryGenerationModelQuery.data} + onSaveSummaryGenerationModel={saveSummaryGenerationModelMutation.mutate} + isSavingSummaryGenerationModel={ + saveSummaryGenerationModelMutation.isPending + } + isSaveSummaryGenerationModelError={ + saveSummaryGenerationModelMutation.isError + } onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate} isSavingExploreModelOverride={ saveExploreModelOverrideMutation.isPending diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx index 03bd43ef8f8..c26c7111dea 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx @@ -42,6 +42,11 @@ const buildTitleGenerationModelOverrideData = ( ): TypesGen.ChatModelOverrideResponse => buildOverrideData("title_generation", overrides); +const buildSummaryGenerationModelOverrideData = ( + overrides: Partial = {}, +): TypesGen.ChatModelOverrideResponse => + buildOverrideData("summary_generation", overrides); + const generalModelConfig = buildModelConfig({ id: "model-general-gpt-4.1-mini", display_name: "GPT 4.1 Mini", @@ -116,6 +121,7 @@ const buildArgs = ( isSaveAdminOverridesError: false, generalModelOverrideData: buildOverrideData("general"), titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData(), + summaryGenerationModelOverrideData: buildSummaryGenerationModelOverrideData(), exploreModelOverrideData: buildOverrideData("explore"), modelConfigsData: allModelConfigs, modelConfigsError: undefined, @@ -126,6 +132,9 @@ const buildArgs = ( onSaveTitleGenerationModel: fn(), isSavingTitleGenerationModel: false, isSaveTitleGenerationModelError: false, + onSaveSummaryGenerationModel: fn(), + isSavingSummaryGenerationModel: false, + isSaveSummaryGenerationModelError: false, onSaveExploreModelOverride: fn(), isSavingExploreModelOverride: false, isSaveExploreModelOverrideError: false, @@ -350,6 +359,46 @@ export const EachOverrideSetToEnabledModel: Story = { }, }; +export const SummaryGenerationModelSetToEnabledModel: Story = { + args: buildArgs({ + summaryGenerationModelOverrideData: buildSummaryGenerationModelOverrideData( + { model_config_id: titleModelConfig.id }, + ), + }), + play: async ({ canvasElement, args }) => { + const summarySection = await getSection( + canvasElement, + "Summary generation model", + ); + + expect( + within(summarySection).getByRole("combobox", { + name: /gpt 4o mini/i, + }), + ).toHaveTextContent("GPT 4o Mini"); + + await selectModelInSection( + summarySection, + canvasElement, + /gpt 4o mini/i, + "Claude Sonnet 4", + ); + const summarySaveButton = within(summarySection).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(summarySaveButton).toBeEnabled(); + }); + await userEvent.click(summarySaveButton); + await waitFor(() => { + expect(args.onSaveSummaryGenerationModel).toHaveBeenCalledWith( + { model_config_id: claudeSonnetModelConfig.id }, + expect.anything(), + ); + }); + }, +}; + export const MalformedOverridesRemainClearableAndSaveable: Story = { args: buildArgs({ generalModelOverrideData: buildOverrideData("general", { diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx index 46e2300975e..e1eb253fe7c 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx @@ -25,6 +25,7 @@ export interface AgentSettingsAgentsPageViewProps { isSaveAdminOverridesError: boolean; generalModelOverrideData?: TypesGen.ChatModelOverrideResponse; titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; + summaryGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse; modelConfigsData: TypesGen.ChatModelConfig[] | undefined; modelConfigsError: unknown; @@ -35,6 +36,9 @@ export interface AgentSettingsAgentsPageViewProps { onSaveTitleGenerationModel: SaveModelOverride; isSavingTitleGenerationModel: boolean; isSaveTitleGenerationModelError: boolean; + onSaveSummaryGenerationModel: SaveModelOverride; + isSavingSummaryGenerationModel: boolean; + isSaveSummaryGenerationModelError: boolean; onSaveExploreModelOverride: SaveModelOverride; isSavingExploreModelOverride: boolean; isSaveExploreModelOverrideError: boolean; @@ -52,6 +56,7 @@ export const AgentSettingsAgentsPageView: FC< isSaveAdminOverridesError, generalModelOverrideData, titleGenerationModelOverrideData, + summaryGenerationModelOverrideData, exploreModelOverrideData, modelConfigsData, modelConfigsError, @@ -62,6 +67,9 @@ export const AgentSettingsAgentsPageView: FC< onSaveTitleGenerationModel, isSavingTitleGenerationModel, isSaveTitleGenerationModelError, + onSaveSummaryGenerationModel, + isSavingSummaryGenerationModel, + isSaveSummaryGenerationModelError, onSaveExploreModelOverride, isSavingExploreModelOverride, isSaveExploreModelOverrideError, @@ -137,6 +145,31 @@ export const AgentSettingsAgentsPageView: FC< showHeader={false} /> +
+ + +
( onSaveTitleGenerationModel={fn()} isSavingTitleGenerationModel={false} isSaveTitleGenerationModelError={false} + summaryGenerationModelOverrideData={{ + context: "summary_generation", + model_config_id: "", + is_malformed: false, + }} + onSaveSummaryGenerationModel={fn()} + isSavingSummaryGenerationModel={false} + isSaveSummaryGenerationModelError={false} onSaveExploreModelOverride={fn()} isSavingExploreModelOverride={false} isSaveExploreModelOverrideError={false} diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 02c3826c146..9e69bc8b196 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -79,6 +79,7 @@ export const WithParentChat: Story = { title: "Set up CI/CD pipeline", status: "completed", last_turn_summary: null, + 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/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx index 3bf3b96c370..763620338d2 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.stories.tsx @@ -29,6 +29,7 @@ const mockChat: Chat = { mcp_server_ids: [], labels: {}, last_turn_summary: "Added migration script", + summary: "Investigated and fixed a race condition in the auth middleware.", created_at: "2026-05-20T05:00:00.000Z", updated_at: "2026-05-20T07:30:00.000Z", archived: false, diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index 2c634ecbb1a..623b962fcb5 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -20,6 +20,7 @@ export const MockChat: Chat = { title: "Agent", status: "completed", last_turn_summary: null, + summary: null, created_at: MOCK_TIMESTAMP, updated_at: MOCK_TIMESTAMP, archived: false, From 3cb7a48d66ec9a3bb9c500f2c2ee957ed23fe184 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 24 Jun 2026 11:35:59 +0000 Subject: [PATCH 02/24] test(coderd): include summary_generation in override context assertion The UnknownContextReturns400 subtest hardcodes the valid override context list in its expected error. Adding the summary_generation context changed the message, so update both assertions. --- coderd/exp_chats_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index c0823dd6bc6..9564202a9cf 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -11548,7 +11548,7 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid chat model override context.", sdkErr.Message) require.Equal( t, - `Expected one of general, explore, title_generation. Got "not-a-context".`, + `Expected one of general, explore, title_generation, summary_generation. Got "not-a-context".`, sdkErr.Detail, ) @@ -11557,7 +11557,7 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid chat model override context.", sdkErr.Message) require.Equal( t, - `Expected one of general, explore, title_generation. Got "not-a-context".`, + `Expected one of general, explore, title_generation, summary_generation. Got "not-a-context".`, sdkErr.Detail, ) }) From 313f8b711ad26642985f325525d11095563eba55 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 07:20:02 +0000 Subject: [PATCH 03/24] fix: address coder-agents-review feedback on chat summary Resolve the actionable findings from the deep review of #26657: - P0/CRF-2: renumber the chat summary migration from 000530 to 000534 to avoid colliding with 000530_relay_host_nats_port on main. - CRF-3: scope summary_change/chat_summary_change merges to their own field so an equal-timestamp event cannot clobber the other summary; add the equal-timestamp regression tests. - CRF-4/CRF-13: bail out of background summary generation when shutdown has begun so Close() is not blocked, and fix the stale comment. - CRF-5: re-read the chat before the cadence gate so rapid turns do not both pass against a stale snapshot. - CRF-7: add a CHECK constraint on chat_messages.cost_source. - CRF-8: use a dedicated chatSummaryWriteTimeout for the summary write. - CRF-17: only count sentence terminators at a word boundary so dotted identifiers do not inflate the count. - CRF-1/CRF-6: deduplicate the usage-recording and model-override resolution paths shared by title and summary generation. - CRF-15/CRF-16: cover ChatMessageVisibilityUser and model-only user messages in the summary tests. - CRF-9/10/11/12/19/22: comment cleanups, errors.AsType, and drop the popover reference from the settings description. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/database/check_constraint.go | 1 + coderd/database/dump.sql | 3 +- .../migrations/000534_chat_summary.up.sql | 6 +- coderd/x/chatd/chatd.go | 234 ++++++++---------- coderd/x/chatd/generation_model_override.go | 90 +++++++ coderd/x/chatd/quickgen.go | 37 ++- coderd/x/chatd/summary_override.go | 61 +---- coderd/x/chatd/summarygen_internal_test.go | 29 +++ coderd/x/chatd/title_override.go | 61 +---- codersdk/chats.go | 5 +- docs/reference/api/chats.md | 2 +- docs/reference/api/schemas.md | 2 +- site/src/api/queries/chats.test.ts | 41 +++ site/src/api/queries/chats.ts | 24 +- site/src/api/typesGenerated.ts | 5 +- .../AgentSettingsAgentsPageView.tsx | 2 +- 18 files changed, 318 insertions(+), 289 deletions(-) create mode 100644 coderd/x/chatd/generation_model_override.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 50cd046d7eb..e40acc93de2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16726,7 +16726,7 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatStatus" }, "summary": { - "description": "Summary is the persisted whole-chat summary shown in the chat summary\npopover. It is generated asynchronously in the background and may be nil\nuntil the first summary has been produced.", + "description": "Summary is the persisted whole-chat summary, generated asynchronously in\nthe background. It is nil until the first summary has been produced.", "type": "string" }, "title": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 601ddc0f5ca..36431abea5a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15030,7 +15030,7 @@ "$ref": "#/definitions/codersdk.ChatStatus" }, "summary": { - "description": "Summary is the persisted whole-chat summary shown in the chat summary\npopover. It is generated asynchronously in the background and may be nil\nuntil the first summary has been produced.", + "description": "Summary is the persisted whole-chat summary, generated asynchronously in\nthe background. It is nil until the first summary has been produced.", "type": "string" }, "title": { diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 76b350816d7..0927a761b2b 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -21,6 +21,7 @@ const ( CheckAibridgeTokenUsagesOutputPriceMicrosCheck CheckConstraint = "aibridge_token_usages_output_price_micros_check" // aibridge_token_usages CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckBoundaryLogsSequenceNumberCheck CheckConstraint = "boundary_logs_sequence_number_check" // boundary_logs + CheckChatMessagesCostSourceCheck CheckConstraint = "chat_messages_cost_source_check" // chat_messages CheckChatModelConfigsAIProviderRequiredWhenActive CheckConstraint = "chat_model_configs_ai_provider_required_when_active" // chat_model_configs CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 1392303bc55..b6139cc8725 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1896,7 +1896,8 @@ CREATE TABLE chat_messages ( provider_response_id text, api_key_id text, revision bigint NOT NULL, - cost_source text + cost_source text, + CONSTRAINT chat_messages_cost_source_check CHECK ((cost_source = ANY (ARRAY['summary'::text, 'title'::text]))) ); CREATE SEQUENCE chat_messages_id_seq diff --git a/coderd/database/migrations/000534_chat_summary.up.sql b/coderd/database/migrations/000534_chat_summary.up.sql index 2d59c0d05bd..0aca7f0e3f1 100644 --- a/coderd/database/migrations/000534_chat_summary.up.sql +++ b/coderd/database/migrations/000534_chat_summary.up.sql @@ -9,9 +9,11 @@ ALTER TABLE chats -- summary and title generation spend can be reported separately from ordinary -- turn spend. NULL means ordinary turn spend; 'summary' and 'title' tag the -- hidden accounting rows written for background summary and manual title --- generation respectively. +-- generation respectively. The CHECK constrains it to the closed discriminator +-- set so a typo or direct SQL write cannot silently corrupt cost attribution +-- (NULL is implicitly allowed). ALTER TABLE chat_messages - ADD COLUMN cost_source TEXT; + ADD COLUMN cost_source TEXT CHECK (cost_source IN ('summary', 'title')); -- Recreate chats_expanded so the new chat columns are exposed to the API. The -- view has an explicit column list, so new columns are otherwise invisible. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index c199057bc2a..9b4d8715fbf 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2887,6 +2887,82 @@ func fantasyUsageToChatMessageUsage(usage fantasy.Usage) codersdk.ChatMessageUsa return chatUsage } +// recordHiddenUsageMessageTx records non-turn spend (background summary or +// manual title generation) as a hidden, soft-deleted accounting message tagged +// with costSource, then restores the chat's last model config. It runs inside +// the caller's transaction against the already-locked chat and does not touch +// any user-visible chat field. costSource also labels the wrapped errors. +func recordHiddenUsageMessageTx( + ctx context.Context, + tx database.Store, + lockedChat database.Chat, + modelConfig database.ChatModelConfig, + usage fantasy.Usage, + activeAPIKeyID string, + costSource string, +) error { + callConfig := codersdk.ChatModelCallConfig{} + if len(modelConfig.Options) > 0 { + if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { + return xerrors.Errorf("parse model call config: %w", err) + } + } + totalCostMicros := chatcost.CalculateTotalCostMicros( + fantasyUsageToChatMessageUsage(usage), + callConfig.Cost, + ) + + // MarshalParts returns a null NullRawMessage for empty slices, which becomes + // an empty string that PostgreSQL rejects as invalid JSON. + content := "[]" + + messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: lockedChat.ID, + CreatedBy: []uuid.UUID{lockedChat.OwnerID}, + APIKeyID: []string{activeAPIKeyID}, + ModelConfigID: []uuid.UUID{modelConfig.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, + Content: []string{content}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel}, + InputTokens: []int64{usage.InputTokens}, + OutputTokens: []int64{usage.OutputTokens}, + TotalTokens: []int64{usage.TotalTokens}, + ReasoningTokens: []int64{usage.ReasoningTokens}, + CacheCreationTokens: []int64{usage.CacheCreationTokens}, + CacheReadTokens: []int64{usage.CacheReadTokens}, + ContextLimit: []int64{modelConfig.ContextLimit}, + Compressed: []bool{false}, + TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)}, + RuntimeMs: []int64{0}, + ProviderResponseID: []string{""}, + }) + if err != nil { + return xerrors.Errorf("insert %s usage message: %w", costSource, err) + } + if len(messages) != 1 { + return xerrors.Errorf("expected 1 %s usage message, got %d", costSource, len(messages)) + } + if _, err := tx.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ + ID: messages[0].ID, + CostSource: costSource, + }); err != nil { + return xerrors.Errorf("tag %s usage cost source: %w", costSource, err) + } + if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { + return xerrors.Errorf("soft delete %s usage message: %w", costSource, err) + } + if lockedChat.LastModelConfigID != modelConfig.ID { + if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{ + ID: lockedChat.ID, + LastModelConfigID: lockedChat.LastModelConfigID, + }); err != nil { + return xerrors.Errorf("restore chat model config after %s usage: %w", costSource, err) + } + } + return nil +} + func recordManualTitleUsage( ctx context.Context, store database.Store, @@ -2901,26 +2977,6 @@ func recordManualTitleUsage( return chat, nil } - var totalCostMicros *int64 - if hasUsage { - callConfig := codersdk.ChatModelCallConfig{} - if len(modelConfig.Options) > 0 { - if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { - return database.Chat{}, xerrors.Errorf("parse model call config: %w", err) - } - } - totalCostMicros = chatcost.CalculateTotalCostMicros( - fantasyUsageToChatMessageUsage(usage), - callConfig.Cost, - ) - } - - // Use a valid empty JSON array for the content column. - // MarshalParts returns a null NullRawMessage for empty - // slices, which becomes an empty string that PostgreSQL - // rejects as invalid JSON. - content := "[]" - updatedChat := chat err := store.InTx(func(tx database.Store) error { lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID) @@ -2929,49 +2985,8 @@ func recordManualTitleUsage( } updatedChat = lockedChat if hasUsage { - messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{ - ChatID: chat.ID, - CreatedBy: []uuid.UUID{chat.OwnerID}, - APIKeyID: []string{activeAPIKeyID}, - ModelConfigID: []uuid.UUID{modelConfig.ID}, - Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, - Content: []string{content}, - ContentVersion: []int16{chatprompt.CurrentContentVersion}, - Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel}, - InputTokens: []int64{usage.InputTokens}, - OutputTokens: []int64{usage.OutputTokens}, - TotalTokens: []int64{usage.TotalTokens}, - ReasoningTokens: []int64{usage.ReasoningTokens}, - CacheCreationTokens: []int64{usage.CacheCreationTokens}, - CacheReadTokens: []int64{usage.CacheReadTokens}, - ContextLimit: []int64{modelConfig.ContextLimit}, - Compressed: []bool{false}, - TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)}, - RuntimeMs: []int64{0}, - ProviderResponseID: []string{""}, - }) - if err != nil { - return xerrors.Errorf("insert manual title usage message: %w", err) - } - if len(messages) != 1 { - return xerrors.Errorf("expected 1 manual title usage message, got %d", len(messages)) - } - if _, err := tx.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ - ID: messages[0].ID, - CostSource: chatCostSourceTitle, - }); err != nil { - return xerrors.Errorf("tag manual title usage cost source: %w", err) - } - if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { - return xerrors.Errorf("soft delete manual title usage message: %w", err) - } - if lockedChat.LastModelConfigID != modelConfig.ID { - if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{ - ID: chat.ID, - LastModelConfigID: lockedChat.LastModelConfigID, - }); err != nil { - return xerrors.Errorf("restore chat model config after manual title usage: %w", err) - } + if err := recordHiddenUsageMessageTx(ctx, tx, lockedChat, modelConfig, usage, activeAPIKeyID, chatCostSourceTitle); err != nil { + return err } } if newTitle != "" && lockedChat.Title == chat.Title && newTitle != lockedChat.Title { @@ -4915,6 +4930,10 @@ const ( chatSummaryWorkTimeout = 120 * time.Second // chatSummaryGenerateTimeout bounds the model call itself. chatSummaryGenerateTimeout = 60 * time.Second + // chatSummaryWriteTimeout bounds the best-effort persistence of a generated + // whole-chat summary. It is separate from turnStatusLabelWriteTimeout so the + // two policies can be tuned independently. + chatSummaryWriteTimeout = 5 * time.Second ) // chatCostSource values tag hidden accounting rows so non-turn spend is @@ -4937,10 +4956,15 @@ func (p *Server) maybeGenerateChatSummaryAsync( if chat.ParentChatID.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. + // Launch background summary generation tracked by p.inflight so Close() + // waits for it. Bail out early if shutdown has begun: generation can run for + // up to chatSummaryWorkTimeout, and Close() must not block that long. The + // atomic inflightClosed check reproduces goInflight's shutdown admission + // gate without taking inflightMu, which drainInflight holds while waiting. p.inflight.Go(func() { + if p.inflightClosed.Load() { + return + } p.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, runResult, logger) }) } @@ -4967,6 +4991,18 @@ func (p *Server) generateAndStoreChatSummary( return } + // Re-read the chat so the cadence gate sees the freshest Summary and + // SummaryGeneratedAt. The chat passed in is a snapshot from when the turn + // finished. Without this, two rapid back-to-back turns both see the same + // stale snapshot, both pass the gate, and both call the LLM, even though the + // history_version guard in UpdateChatSummary still persists only one. + chat, err = p.db.GetChatByID(authCtx, chat.ID) + if err != nil { + logger.Debug(ctx, "failed to re-read chat for summary", + slog.F("chat_id", chat.ID), slog.Error(err)) + return + } + if !shouldGenerateChatSummary(chat, messages) { return } @@ -5105,7 +5141,7 @@ func (p *Server) updateChatSummary( //nolint:gocritic // Narrow daemon access for best-effort summary cache writes. updateCtx := dbauthz.AsChatd(ctx) - updateCtx, cancel := context.WithTimeout(updateCtx, turnStatusLabelWriteTimeout) + updateCtx, cancel := context.WithTimeout(updateCtx, chatSummaryWriteTimeout) defer cancel() affected, err := p.db.UpdateChatSummary(updateCtx, database.UpdateChatSummaryParams{ @@ -5134,7 +5170,8 @@ func (p *Server) updateChatSummary( // recordChatSummaryUsage records the cost of a background summary generation as // a hidden, soft-deleted accounting row tagged with cost_source='summary'. It -// mirrors recordManualTitleUsage but does not update the chat title. +// locks the chat and delegates to recordHiddenUsageMessageTx; unlike +// recordManualTitleUsage it never updates the chat title. func recordChatSummaryUsage( ctx context.Context, store database.Store, @@ -5147,20 +5184,6 @@ func recordChatSummaryUsage( return chat, nil } - callConfig := codersdk.ChatModelCallConfig{} - if len(modelConfig.Options) > 0 { - if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { - return database.Chat{}, xerrors.Errorf("parse model call config: %w", err) - } - } - totalCostMicros := chatcost.CalculateTotalCostMicros( - fantasyUsageToChatMessageUsage(usage), - callConfig.Cost, - ) - - // Use a valid empty JSON array for the content column. - content := "[]" - updatedChat := chat err := store.InTx(func(tx database.Store) error { lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID) @@ -5168,52 +5191,7 @@ func recordChatSummaryUsage( return xerrors.Errorf("lock chat for summary usage: %w", err) } updatedChat = lockedChat - - messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{ - ChatID: chat.ID, - CreatedBy: []uuid.UUID{chat.OwnerID}, - APIKeyID: []string{activeAPIKeyID}, - ModelConfigID: []uuid.UUID{modelConfig.ID}, - Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, - Content: []string{content}, - ContentVersion: []int16{chatprompt.CurrentContentVersion}, - Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel}, - InputTokens: []int64{usage.InputTokens}, - OutputTokens: []int64{usage.OutputTokens}, - TotalTokens: []int64{usage.TotalTokens}, - ReasoningTokens: []int64{usage.ReasoningTokens}, - CacheCreationTokens: []int64{usage.CacheCreationTokens}, - CacheReadTokens: []int64{usage.CacheReadTokens}, - ContextLimit: []int64{modelConfig.ContextLimit}, - Compressed: []bool{false}, - TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)}, - RuntimeMs: []int64{0}, - ProviderResponseID: []string{""}, - }) - if err != nil { - return xerrors.Errorf("insert summary usage message: %w", err) - } - if len(messages) != 1 { - return xerrors.Errorf("expected 1 summary usage message, got %d", len(messages)) - } - if _, err := tx.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ - ID: messages[0].ID, - CostSource: chatCostSourceSummary, - }); err != nil { - return xerrors.Errorf("tag summary usage cost source: %w", err) - } - if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { - return xerrors.Errorf("soft delete summary usage message: %w", err) - } - if lockedChat.LastModelConfigID != modelConfig.ID { - if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{ - ID: chat.ID, - LastModelConfigID: lockedChat.LastModelConfigID, - }); err != nil { - return xerrors.Errorf("restore chat model config after summary usage: %w", err) - } - } - return nil + return recordHiddenUsageMessageTx(ctx, tx, lockedChat, modelConfig, usage, activeAPIKeyID, chatCostSourceSummary) }, nil) if err != nil { return database.Chat{}, err diff --git a/coderd/x/chatd/generation_model_override.go b/coderd/x/chatd/generation_model_override.go new file mode 100644 index 00000000000..a3ff1ac7637 --- /dev/null +++ b/coderd/x/chatd/generation_model_override.go @@ -0,0 +1,90 @@ +package chatd + +import ( + "context" + + "charm.land/fantasy" + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" +) + +// resolveGenerationModelOverride resolves a deployment-wide model override for a +// background generation feature (title or summary). overrideContext labels the +// override (and its error messages); readOverride loads the configured value. +// overrideSet is true when an override was configured; in that case any returned +// error is a hard failure and the caller should skip generation. When +// overrideSet is false, callers fall back to the chat's configured model. +func (p *Server) resolveGenerationModelOverride( + ctx context.Context, + chat database.Chat, + keys chatprovider.ProviderAPIKeys, + modelOpts modelBuildOptions, + overrideContext string, + readOverride func(context.Context, database.Store) (string, error), +) (database.ChatModelConfig, fantasy.LanguageModel, chatprovider.ProviderAPIKeys, resolvedModelRoute, bool, error) { + label := modelOverrideErrorLabel(overrideContext) + raw, err := readOverride(ctx, p.db) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, false, xerrors.Errorf( + "read %s model override: %w", + label, + err, + ) + } + + overrideProviderKeys := keys + modelConfig, overrideSet, err := p.resolveConfiguredModelOverride( + ctx, + overrideContext, + raw, + chat.OwnerID, + p.resolveModelConfigAndNormalizedProvider, + func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { + if aiProviderID == uuid.Nil { + resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, uuid.Nil) + if err != nil || resolvedProviderKeys.Empty() { + resolvedProviderKeys = keys + } + overrideProviderKeys = resolvedProviderKeys + return resolvedProviderKeys, nil + } + resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) + if err != nil { + return chatprovider.ProviderAPIKeys{}, err + } + overrideProviderKeys = resolvedProviderKeys + return resolvedProviderKeys, nil + }, + modelOverrideFailureModeHard, + ) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, overrideSet, err + } + if !overrideSet { + return database.ChatModelConfig{}, nil, keys, resolvedModelRoute{}, false, nil + } + + //nolint:gocritic // Overrides need chatd-scoped provider reads for user-owned chats. + route, err := p.resolveModelRouteForConfig(dbauthz.AsChatd(ctx), chat.OwnerID, modelConfig, overrideProviderKeys) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, err + } + model, err := p.newModel(ctx, modelClientRequest{ + Chat: chat, + ModelName: modelConfig.Model, + UserAgent: chatprovider.UserAgent(), + ExtraHeaders: chatprovider.CoderHeaders(chat), + }, route, modelOpts) + if err != nil { + return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, xerrors.Errorf( + "create %s model override: %w", + label, + err, + ) + } + return modelConfig, model, route.directProviderKeys(), route, true, nil +} diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 1414882c05c..9de9bc08767 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -7,6 +7,7 @@ import ( "slices" "strings" "time" + "unicode" "charm.land/fantasy" "charm.land/fantasy/object" @@ -889,9 +890,7 @@ const ( // very long message (such as a replayed compaction summary) cannot dominate // the transcript budget. summaryTranscriptPerMessageMaxRunes = 4000 - // summaryMaxOutputTokens bounds the generated summary length. Combined with - // the prompt, this keeps the summary to a short blurb. - summaryMaxOutputTokens = 512 + summaryMaxOutputTokens = 512 // summaryMaxRunes rejects pathologically long summaries that ignore the // length instruction. summaryMaxRunes = 1000 @@ -905,15 +904,10 @@ type generatedChatSummary struct { Summary string `json:"summary" description:"1-3 sentence summary of the whole chat"` } -// renderChatSummaryTranscript renders compaction-aware chat history into a -// plain-text transcript for whole-chat summary generation. It includes user and -// assistant text, intentionally keeping the replayed compaction summary (a -// model-only compressed message) so the summary still covers pre-compaction -// content, and skips the system prompt and tool-call/tool-result noise. -// -// The history is rendered as plain transcript text rather than replayed as raw -// fantasy messages so that provider tool-call pairing rules do not apply to -// historical tool messages during structured (object.Generate) generation. +// renderChatSummaryTranscript renders chat history as plain text for summary +// generation. It keeps the compaction summary so pre-compaction content is +// covered. Plain text avoids provider tool-call pairing rules during structured +// generation. func renderChatSummaryTranscript(messages []database.ChatMessage) string { lines := make([]string, 0, len(messages)) for _, message := range messages { @@ -1042,8 +1036,7 @@ func generateChatSummary( }, nil) if err != nil { var usage fantasy.Usage - var noObjErr *fantasy.NoObjectGeneratedError - if errors.As(err, &noObjErr) { + if noObjErr, ok := errors.AsType[*fantasy.NoObjectGeneratedError](err); ok { usage = noObjErr.Usage } return "", usage, xerrors.Errorf("generate chat summary: %w", err) @@ -1069,13 +1062,19 @@ func validateGeneratedChatSummary(summary string) error { return nil } -// countSentenceTerminators counts sentence-ending punctuation. It is an -// approximation used only as a safety net against pathologically verbose -// output, so abbreviations inflating the count slightly is acceptable. +// countSentenceTerminators counts sentence-ending punctuation. It only counts a +// terminator that is followed by whitespace or ends the text, so periods inside +// dotted identifiers (pkg.cmd.server, file paths) that the prompt asks the model +// to preserve do not inflate the count. It is an approximation used only as a +// safety net against pathologically verbose output. func countSentenceTerminators(text string) int { + runes := []rune(text) count := 0 - for _, r := range text { - if r == '.' || r == '!' || r == '?' { + for i, r := range runes { + if r != '.' && r != '!' && r != '?' { + continue + } + if i == len(runes)-1 || unicode.IsSpace(runes[i+1]) { count++ } } diff --git a/coderd/x/chatd/summary_override.go b/coderd/x/chatd/summary_override.go index 7403947bb71..81da7ae167d 100644 --- a/coderd/x/chatd/summary_override.go +++ b/coderd/x/chatd/summary_override.go @@ -4,7 +4,6 @@ import ( "context" "charm.land/fantasy" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" @@ -41,62 +40,8 @@ func (p *Server) resolveSummaryGenerationModelOverride( keys chatprovider.ProviderAPIKeys, modelOpts modelBuildOptions, ) (database.ChatModelConfig, fantasy.LanguageModel, chatprovider.ProviderAPIKeys, resolvedModelRoute, bool, error) { - raw, err := readSummaryGenerationModelOverride(ctx, p.db) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, false, xerrors.Errorf( - "read summary generation model override: %w", - err, - ) - } - - overrideProviderKeys := keys - modelConfig, overrideSet, err := p.resolveConfiguredModelOverride( - ctx, - summaryGenerationOverrideContext, - raw, - chat.OwnerID, - p.resolveModelConfigAndNormalizedProvider, - func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { - if aiProviderID == uuid.Nil { - resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, uuid.Nil) - if err != nil || resolvedProviderKeys.Empty() { - resolvedProviderKeys = keys - } - overrideProviderKeys = resolvedProviderKeys - return resolvedProviderKeys, nil - } - resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) - if err != nil { - return chatprovider.ProviderAPIKeys{}, err - } - overrideProviderKeys = resolvedProviderKeys - return resolvedProviderKeys, nil - }, - modelOverrideFailureModeHard, + return p.resolveGenerationModelOverride( + ctx, chat, keys, modelOpts, + summaryGenerationOverrideContext, readSummaryGenerationModelOverride, ) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, overrideSet, err - } - if !overrideSet { - return database.ChatModelConfig{}, nil, keys, resolvedModelRoute{}, false, nil - } - - //nolint:gocritic // Summary overrides need chatd-scoped provider reads for user-owned chats. - route, err := p.resolveModelRouteForConfig(dbauthz.AsChatd(ctx), chat.OwnerID, modelConfig, overrideProviderKeys) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, err - } - model, err := p.newModel(ctx, modelClientRequest{ - Chat: chat, - ModelName: modelConfig.Model, - UserAgent: chatprovider.UserAgent(), - ExtraHeaders: chatprovider.CoderHeaders(chat), - }, route, modelOpts) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, xerrors.Errorf( - "create summary generation model override: %w", - err, - ) - } - return modelConfig, model, route.directProviderKeys(), route, true, nil } diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index e93156ec0cd..4e3cf9c330f 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -64,6 +64,8 @@ func TestRenderChatSummaryTranscript(t *testing.T) { summaryTextMessage(t, 3, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "AGENTS.md injected context", false, base.Add(2*time.Minute)), // Normal visible turn. summaryTextMessage(t, 4, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "fix the bug in foo.go", false, base.Add(3*time.Minute)), + // User-only message ("user" visibility) is also included. + summaryTextMessage(t, 8, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "and please keep it simple", false, base.Add(3*time.Minute+30*time.Second)), // Assistant tool-call only message (no text) is skipped. summaryTestMessage(t, 5, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, []codersdk.ChatMessagePart{codersdk.ChatMessageToolCall("call-1", "bash", []byte(`{"cmd":"go test"}`))}, @@ -78,6 +80,7 @@ func TestRenderChatSummaryTranscript(t *testing.T) { require.Contains(t, transcript, "earlier work compaction summary") require.Contains(t, transcript, "[user]: fix the bug in foo.go") + require.Contains(t, transcript, "[user]: and please keep it simple") require.Contains(t, transcript, "[assistant]: fixed the bug and added a test") // System prompt, injected context, tool-call, and tool result are excluded. require.NotContains(t, transcript, "you are a helpful agent") @@ -166,6 +169,32 @@ func TestShouldGenerateChatSummary(t *testing.T) { require.False(t, shouldGenerateChatSummary(chat, msgs)) }) + t.Run("ModelOnlyUserMessagesAreNotTurns", func(t *testing.T) { + t.Parallel() + marker := base + chat := database.Chat{ + Summary: sql.NullString{String: "existing", Valid: true}, + SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true}, + } + modelOnlyUserMsg := func(id int64, at time.Time) database.ChatMessage { + return database.ChatMessage{ + ID: id, + Role: database.ChatMessageRoleUser, + Visibility: database.ChatMessageVisibilityModel, + CreatedAt: at, + } + } + // Two real user turns stay below the refresh threshold of 3. The + // model-only user message (such as injected AGENTS.md context) must not + // count as a turn; if it did, these three messages would trip the gate. + msgs := []database.ChatMessage{ + userMsg(1, marker.Add(time.Minute)), + modelOnlyUserMsg(2, marker.Add(2*time.Minute)), + userMsg(3, marker.Add(3*time.Minute)), + } + require.False(t, shouldGenerateChatSummary(chat, msgs)) + }) + t.Run("RefreshAfterThresholdTurns", func(t *testing.T) { t.Parallel() marker := base diff --git a/coderd/x/chatd/title_override.go b/coderd/x/chatd/title_override.go index 9840a3b471c..27d4fcad443 100644 --- a/coderd/x/chatd/title_override.go +++ b/coderd/x/chatd/title_override.go @@ -4,7 +4,6 @@ import ( "context" "charm.land/fantasy" - "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" @@ -40,62 +39,8 @@ func (p *Server) resolveTitleGenerationModelOverride( keys chatprovider.ProviderAPIKeys, modelOpts modelBuildOptions, ) (database.ChatModelConfig, fantasy.LanguageModel, chatprovider.ProviderAPIKeys, resolvedModelRoute, bool, error) { - raw, err := readTitleGenerationModelOverride(ctx, p.db) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, false, xerrors.Errorf( - "read title generation model override: %w", - err, - ) - } - - overrideProviderKeys := keys - modelConfig, overrideSet, err := p.resolveConfiguredModelOverride( - ctx, - titleGenerationOverrideContext, - raw, - chat.OwnerID, - p.resolveModelConfigAndNormalizedProvider, - func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { - if aiProviderID == uuid.Nil { - resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, uuid.Nil) - if err != nil || resolvedProviderKeys.Empty() { - resolvedProviderKeys = keys - } - overrideProviderKeys = resolvedProviderKeys - return resolvedProviderKeys, nil - } - resolvedProviderKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) - if err != nil { - return chatprovider.ProviderAPIKeys{}, err - } - overrideProviderKeys = resolvedProviderKeys - return resolvedProviderKeys, nil - }, - modelOverrideFailureModeHard, + return p.resolveGenerationModelOverride( + ctx, chat, keys, modelOpts, + titleGenerationOverrideContext, readTitleGenerationModelOverride, ) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, overrideSet, err - } - if !overrideSet { - return database.ChatModelConfig{}, nil, keys, resolvedModelRoute{}, false, nil - } - - //nolint:gocritic // Title overrides need chatd-scoped provider reads for user-owned chats. - route, err := p.resolveModelRouteForConfig(dbauthz.AsChatd(ctx), chat.OwnerID, modelConfig, overrideProviderKeys) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, err - } - model, err := p.newModel(ctx, modelClientRequest{ - Chat: chat, - ModelName: modelConfig.Model, - UserAgent: chatprovider.UserAgent(), - ExtraHeaders: chatprovider.CoderHeaders(chat), - }, route, modelOpts) - if err != nil { - return database.ChatModelConfig{}, nil, chatprovider.ProviderAPIKeys{}, resolvedModelRoute{}, true, xerrors.Errorf( - "create title generation model override: %w", - err, - ) - } - return modelConfig, model, route.directProviderKeys(), route, true, nil } diff --git a/codersdk/chats.go b/codersdk/chats.go index accee885731..01d2a5feda0 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -123,9 +123,8 @@ type Chat struct { PlanMode ChatPlanMode `json:"plan_mode,omitempty"` LastError *ChatError `json:"last_error,omitempty"` LastTurnSummary *string `json:"last_turn_summary"` - // Summary is the persisted whole-chat summary shown in the chat summary - // popover. It is generated asynchronously in the background and may be nil - // until the first summary has been produced. + // Summary is the persisted whole-chat summary, generated asynchronously in + // the background. It is nil until the first summary has been produced. Summary *string `json:"summary"` DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` CreatedAt time.Time `json:"created_at" format:"date-time"` diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index eb1c7e86c75..a80ec1bc2f6 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -217,7 +217,7 @@ Status Code **200** | `» root_chat_id` | string(uuid) | false | | | | `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» summary` | string | false | | Summary is the persisted whole-chat summary shown in the chat summary popover. It is generated asynchronously in the background and may be nil until the first summary has been produced. | +| `» summary` | string | false | | Summary is the persisted whole-chat summary, generated asynchronously in the background. It is nil until the first summary has been produced. | | `» title` | string | false | | | | `» updated_at` | string(date-time) | false | | | | `» warnings` | array | false | | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index bde14f3881d..1c4f6d40ef8 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2229,7 +2229,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `root_chat_id` | string | false | | | | `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | -| `summary` | string | false | | Summary is the persisted whole-chat summary shown in the chat summary popover. It is generated asynchronously in the background and may be nil until the first summary has been produced. | +| `summary` | string | false | | Summary is the persisted whole-chat summary, generated asynchronously in the background. It is nil until the first summary has been produced. | | `title` | string | false | | | | `updated_at` | string | false | | | | `warnings` | array of string | false | | | diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 9bd6c583ce7..c3061a0c590 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2388,6 +2388,47 @@ describe("mergeWatchedChatSummary", () => { expect(merged.last_turn_summary).toBe("Latest turn"); }); + it("does not clobber the whole-chat summary on a summary_change with equal updated_at", () => { + const cachedChat = makeChat("chat-1", { + summary: "Whole-chat summary.", + last_turn_summary: "Old turn", + updated_at: "2025-01-01T00:00:00.000Z", + }); + // summary_change and chat_summary_change share the triggering turn's + // updated_at, so the timestamps are equal. The summary_change snapshot + // still carries a stale whole-chat summary from when the turn finished. + const watchedChat = makeChat("chat-1", { + summary: null, + last_turn_summary: "New turn", + updated_at: "2025-01-01T00:00:00.000Z", + }); + + const merged = mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "summary_change", + }); + expect(merged.last_turn_summary).toBe("New turn"); + expect(merged.summary).toBe("Whole-chat summary."); + }); + + it("does not clobber last_turn_summary on a chat_summary_change with equal updated_at", () => { + const cachedChat = makeChat("chat-1", { + summary: null, + last_turn_summary: "Latest turn", + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + summary: "Implemented the whole-chat summary feature.", + last_turn_summary: "Stale turn", + updated_at: "2025-01-01T00:00:00.000Z", + }); + + const merged = mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "chat_summary_change", + }); + expect(merged.summary).toBe("Implemented the whole-chat summary feature."); + expect(merged.last_turn_summary).toBe("Latest turn"); + }); + it("clears last_turn_summary on summary updates with matching updated_at", () => { const cachedChat = makeChat("chat-1", { last_turn_summary: "Previous summary", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 8730746089b..34b12adaa7a 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -457,18 +457,18 @@ 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; - // The whole-chat summary is delivered via its own chat_summary_change event - // and preserves updated_at, so apply it even when the cached timestamp is - // newer. A distinct event ensures it only touches summary, not - // last_turn_summary. - const nextSummary = - isFreshEnough || isChatSummaryEvent - ? watchedChat.summary - : cachedChat.summary; + // last_turn_summary and the whole-chat summary each have a dedicated event, + // and both preserve the triggering turn's updated_at, so summary_change and + // chat_summary_change carry an equal timestamp. Scope each field strictly to + // its own event: keying off isFreshEnough would let a summary_change clobber + // summary (and a chat_summary_change clobber last_turn_summary) with the + // event's stale snapshot of the other field. + const nextLastTurnSummary = isSummaryEvent + ? watchedChat.last_turn_summary + : cachedChat.last_turn_summary; + const nextSummary = isChatSummaryEvent + ? watchedChat.summary + : cachedChat.summary; const nextHasUnread = isFreshEnough && isStatusEvent && watchedChat.id !== activeChatId ? true diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5a699406673..11958623012 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1567,9 +1567,8 @@ export interface Chat { readonly last_error?: ChatError; readonly last_turn_summary: string | null; /** - * Summary is the persisted whole-chat summary shown in the chat summary - * popover. It is generated asynchronously in the background and may be nil - * until the first summary has been produced. + * Summary is the persisted whole-chat summary, generated asynchronously in + * the background. It is nil until the first summary has been produced. */ readonly summary: string | null; readonly diff_status?: ChatDiffStatus; diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx index e1eb253fe7c..4a69927ce5f 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx @@ -151,7 +151,7 @@ export const AgentSettingsAgentsPageView: FC< > Date: Mon, 29 Jun 2026 08:33:30 +0000 Subject: [PATCH 04/24] test(coderd/x/chatd): pin sentence-terminator boundary behavior CRF-23: add TestCountSentenceTerminators so the CRF-17 fix is covered. Without it, removing the word-boundary guard in countSentenceTerminators would not fail any test. The test asserts periods inside dotted identifiers are not counted and that a dotted-identifier-dense summary stays under summaryMaxSentences. --- coderd/x/chatd/summarygen_internal_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index 4e3cf9c330f..ac82cc36935 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -221,3 +221,19 @@ func TestValidateGeneratedChatSummary(t *testing.T) { require.Error(t, validateGeneratedChatSummary("One. Two. Three. Four. Five. Six. Seven.")) require.NoError(t, validateGeneratedChatSummary("Implemented the summary feature. Added tests.")) } + +func TestCountSentenceTerminators(t *testing.T) { + t.Parallel() + + // Only terminators followed by whitespace or end-of-text count, so periods + // inside dotted identifiers are not treated as sentence boundaries. + require.Equal(t, 2, countSentenceTerminators("Fixed pkg.cmd.server in file.go. Added a test.")) + require.Equal(t, 3, countSentenceTerminators("One. Two! Three?")) + require.Equal(t, 0, countSentenceTerminators("auth.rbac.Policy")) + + // A summary dense with dotted identifiers stays under summaryMaxSentences + // and is accepted, where naive per-period counting would reject it. + require.NoError(t, validateGeneratedChatSummary( + "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go. Added coverage in foo_test.go.", + )) +} From dc93f55accfe037928d06cff9dd2df86bffae82b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 10:02:13 +0000 Subject: [PATCH 05/24] refactor(coderd/x/chatd): defer summary model override Move summary generation model overrides out of the persisted summary PR so the base feature always uses the chat model. Co-authored-by: Cursor --- coderd/database/dbauthz/dbauthz.go | 14 -- coderd/database/dbauthz/dbauthz_test.go | 8 -- coderd/database/dbmetrics/querymetrics.go | 16 --- coderd/database/dbmock/dbmock.go | 29 ----- coderd/database/querier.go | 2 - coderd/database/queries.sql.go | 22 ---- coderd/database/queries/siteconfig.sql | 8 -- coderd/exp_chats.go | 6 - coderd/exp_chats_test.go | 14 +- coderd/x/chatd/chatd.go | 28 +--- coderd/x/chatd/summary_override.go | 47 ------- .../x/chatd/summary_override_internal_test.go | 120 ------------------ codersdk/chats.go | 11 +- site/src/api/typesGenerated.ts | 2 - .../AgentsPage/AgentSettingsAgentsPage.tsx | 20 --- .../AgentSettingsAgentsPageView.stories.tsx | 49 ------- .../AgentSettingsAgentsPageView.tsx | 33 ----- .../AgentsPage/AgentsPageView.stories.tsx | 8 -- 18 files changed, 8 insertions(+), 429 deletions(-) delete mode 100644 coderd/x/chatd/summary_override.go delete mode 100644 coderd/x/chatd/summary_override_internal_test.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b572e81ad7e..f46c9234cf4 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3443,13 +3443,6 @@ func (q *querier) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([ return q.db.GetChatStreamSyncRows(ctx, ids) } -func (q *querier) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return "", err - } - return q.db.GetChatSummaryGenerationModelOverride(ctx) -} - func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) { // The system prompt is a deployment-wide setting read during chat // creation by every authenticated user, so no RBAC policy check @@ -8727,13 +8720,6 @@ func (q *querier) UpsertChatRetentionDays(ctx context.Context, retentionDays int return q.db.UpsertChatRetentionDays(ctx, retentionDays) } -func (q *querier) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertChatSummaryGenerationModelOverride(ctx, value) -} - func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ef1bfa4ebaf..19d658aefee 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1194,10 +1194,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) })) - s.Run("GetChatSummaryGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) s.Run("GetChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) @@ -1670,10 +1666,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatTitleGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("UpsertChatSummaryGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().UpsertChatSummaryGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() - check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) - })) s.Run("UpsertChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 11a4c3c920d..9798c1a2cb1 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1762,14 +1762,6 @@ func (m queryMetricsStore) GetChatStreamSyncRows(ctx context.Context, ids []uuid return r0, r1 } -func (m queryMetricsStore) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { - start := time.Now() - r0, r1 := m.s.GetChatSummaryGenerationModelOverride(ctx) - m.queryLatencies.WithLabelValues("GetChatSummaryGenerationModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSummaryGenerationModelOverride").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetChatSystemPrompt(ctx) @@ -6234,14 +6226,6 @@ func (m queryMetricsStore) UpsertChatRetentionDays(ctx context.Context, retentio return r0 } -func (m queryMetricsStore) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { - start := time.Now() - r0 := m.s.UpsertChatSummaryGenerationModelOverride(ctx, value) - m.queryLatencies.WithLabelValues("UpsertChatSummaryGenerationModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatSummaryGenerationModelOverride").Inc() - return r0 -} - func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertChatSystemPrompt(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index d8f6e31f395..62e9b132b08 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3252,21 +3252,6 @@ func (mr *MockStoreMockRecorder) GetChatStreamSyncRows(ctx, ids any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatStreamSyncRows", reflect.TypeOf((*MockStore)(nil).GetChatStreamSyncRows), ctx, ids) } -// GetChatSummaryGenerationModelOverride mocks base method. -func (m *MockStore) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatSummaryGenerationModelOverride", ctx) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatSummaryGenerationModelOverride indicates an expected call of GetChatSummaryGenerationModelOverride. -func (mr *MockStoreMockRecorder) GetChatSummaryGenerationModelOverride(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSummaryGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatSummaryGenerationModelOverride), ctx) -} - // GetChatSystemPrompt mocks base method. func (m *MockStore) GetChatSystemPrompt(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -11667,20 +11652,6 @@ func (mr *MockStoreMockRecorder) UpsertChatRetentionDays(ctx, retentionDays any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatRetentionDays", reflect.TypeOf((*MockStore)(nil).UpsertChatRetentionDays), ctx, retentionDays) } -// UpsertChatSummaryGenerationModelOverride mocks base method. -func (m *MockStore) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertChatSummaryGenerationModelOverride", ctx, value) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertChatSummaryGenerationModelOverride indicates an expected call of UpsertChatSummaryGenerationModelOverride. -func (mr *MockStoreMockRecorder) UpsertChatSummaryGenerationModelOverride(ctx, value any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSummaryGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatSummaryGenerationModelOverride), ctx, value) -} - // UpsertChatSystemPrompt mocks base method. func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ef4695cee26..10de37dcb91 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -466,7 +466,6 @@ type sqlcQuerier interface { // A value of 0 disables chat purging entirely. GetChatRetentionDays(ctx context.Context) (int32, error) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]GetChatStreamSyncRowsRow, error) - GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) GetChatSystemPrompt(ctx context.Context) (string, error) // GetChatSystemPromptConfig returns both chat system prompt settings in a // single read to avoid torn reads between separate site-config lookups. @@ -1554,7 +1553,6 @@ type sqlcQuerier interface { UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error UpsertChatPlanModeInstructions(ctx context.Context, value string) error UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error - UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b938195c266..2d9a276e050 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -24593,18 +24593,6 @@ func (q *sqlQuerier) GetChatRetentionDays(ctx context.Context) (int32, error) { return retention_days, err } -const getChatSummaryGenerationModelOverride = `-- name: GetChatSummaryGenerationModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_summary_generation_model_override'), '') :: text AS model_config_id -` - -func (q *sqlQuerier) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatSummaryGenerationModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt @@ -25054,16 +25042,6 @@ func (q *sqlQuerier) UpsertChatRetentionDays(ctx context.Context, retentionDays return err } -const upsertChatSummaryGenerationModelOverride = `-- name: UpsertChatSummaryGenerationModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_summary_generation_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_summary_generation_model_override' -` - -func (q *sqlQuerier) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatSummaryGenerationModelOverride, value) - return err -} - const upsertChatSystemPrompt = `-- name: UpsertChatSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 93877b9b514..709cd287ca6 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -191,14 +191,6 @@ SELECT INSERT INTO site_configs (key, value) VALUES ('agents_chat_title_generation_model_override', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_title_generation_model_override'; --- name: GetChatSummaryGenerationModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_summary_generation_model_override'), '') :: text AS model_config_id; - --- name: UpsertChatSummaryGenerationModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_summary_generation_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_summary_generation_model_override'; - -- name: GetChatDesktopEnabled :one SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index c392cb3e2e1..a6f21e881d1 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -621,12 +621,6 @@ func (api *API) chatModelOverrideSiteConfig( getter: api.Database.GetChatTitleGenerationModelOverride, upsert: api.Database.UpsertChatTitleGenerationModelOverride, }, nil - case codersdk.ChatModelOverrideContextSummaryGeneration: - return chatModelOverrideSiteConfig{ - label: "summary generation", - getter: api.Database.GetChatSummaryGenerationModelOverride, - upsert: api.Database.UpsertChatSummaryGenerationModelOverride, - }, nil default: return chatModelOverrideSiteConfig{}, xerrors.Errorf( "unknown chat model override context %q", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 21ae80c784e..acadc1441e7 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -11474,16 +11474,6 @@ func TestChatModelOverrides(t *testing.T) { return db.UpsertChatTitleGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) }, }, - { - name: "SummaryGeneration", - context: codersdk.ChatModelOverrideContextSummaryGeneration, - dbGet: func(ctx context.Context, db database.Store) (string, error) { - return db.GetChatSummaryGenerationModelOverride(dbauthz.AsSystemRestricted(ctx)) - }, - dbUpsert: func(ctx context.Context, db database.Store, value string) error { - return db.UpsertChatSummaryGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) - }, - }, } for _, setting := range settings { @@ -11629,7 +11619,7 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid chat model override context.", sdkErr.Message) require.Equal( t, - `Expected one of general, explore, title_generation, summary_generation. Got "not-a-context".`, + `Expected one of general, explore, title_generation. Got "not-a-context".`, sdkErr.Detail, ) @@ -11638,7 +11628,7 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid chat model override context.", sdkErr.Message) require.Equal( t, - `Expected one of general, explore, title_generation, summary_generation. Got "not-a-context".`, + `Expected one of general, explore, title_generation. Got "not-a-context".`, sdkErr.Detail, ) }) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9b4d8715fbf..933cdf8e4d8 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5044,38 +5044,14 @@ func (p *Server) generateAndStoreChatSummary( p.updateChatSummary(ctx, chat, chat.HistoryVersion, summary, logger) } -// resolveChatSummaryModel resolves the model for summary generation. It prefers -// the deployment summary-generation override when set; a configured-but-unusable -// override is a hard failure that skips generation (preserving any existing -// summary). Otherwise it falls back to the chat's configured model. +// resolveChatSummaryModel resolves the chat's configured model for summary +// generation. func (p *Server) resolveChatSummaryModel( ctx context.Context, chat database.Chat, runResult runChatResult, logger slog.Logger, ) (fantasy.LanguageModel, database.ChatModelConfig, bool) { - overrideConfig, overrideModel, _, _, overrideSet, overrideErr := p.resolveSummaryGenerationModelOverride( - ctx, chat, runResult.ProviderKeys, runResult.ModelBuildOptions, - ) - if overrideErr != nil { - if overrideSet { - logger.Warn(ctx, "summary generation model override unavailable, skipping summary generation", - slog.F("chat_id", chat.ID), - slog.F("override_context", summaryGenerationOverrideContext), - slog.Error(overrideErr), - ) - return nil, database.ChatModelConfig{}, false - } - logger.Debug(ctx, "failed to resolve summary generation model override", - slog.F("chat_id", chat.ID), - slog.F("override_context", summaryGenerationOverrideContext), - slog.Error(overrideErr), - ) - } - if overrideSet { - return overrideModel, overrideConfig, true - } - //nolint:dogsled // resolveChatModel returns rich routing metadata; summary generation only needs the model and its config. model, dbConfig, _, _, _, _, _, err := p.resolveChatModel(ctx, chat, runResult.ModelBuildOptions) if err != nil { diff --git a/coderd/x/chatd/summary_override.go b/coderd/x/chatd/summary_override.go deleted file mode 100644 index 81da7ae167d..00000000000 --- a/coderd/x/chatd/summary_override.go +++ /dev/null @@ -1,47 +0,0 @@ -package chatd - -import ( - "context" - - "charm.land/fantasy" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" -) - -const summaryGenerationOverrideContext = "summary_generation" - -func readSummaryGenerationModelOverride( - ctx context.Context, - db database.Store, -) (string, error) { - //nolint:gocritic // Chatd is internal, not a user, so this read uses AsChatd. - chatdCtx := dbauthz.AsChatd(ctx) - raw, err := db.GetChatSummaryGenerationModelOverride(chatdCtx) - if err != nil { - return "", xerrors.Errorf( - "get chat summary generation model override: %w", - err, - ) - } - return raw, nil -} - -// resolveSummaryGenerationModelOverride resolves the deployment-wide summary -// generation model override. overrideSet is true when an override was -// configured; in that case any returned error is a hard failure and the caller -// should skip summary generation. When overrideSet is false, callers fall back -// to the chat's configured model. -func (p *Server) resolveSummaryGenerationModelOverride( - ctx context.Context, - chat database.Chat, - keys chatprovider.ProviderAPIKeys, - modelOpts modelBuildOptions, -) (database.ChatModelConfig, fantasy.LanguageModel, chatprovider.ProviderAPIKeys, resolvedModelRoute, bool, error) { - return p.resolveGenerationModelOverride( - ctx, chat, keys, modelOpts, - summaryGenerationOverrideContext, readSummaryGenerationModelOverride, - ) -} diff --git a/coderd/x/chatd/summary_override_internal_test.go b/coderd/x/chatd/summary_override_internal_test.go deleted file mode 100644 index 557b538c957..00000000000 --- a/coderd/x/chatd/summary_override_internal_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package chatd - -import ( - "testing" - - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbmock" - "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" - "github.com/coder/coder/v2/testutil" -) - -func TestResolveSummaryGenerationModelOverride_Unset(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - chat, _ := titleOverrideTestChatAndMessages(t) - - db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("", nil) - - server := titleOverrideTestServer(db, logger) - config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( - ctx, - chat, - chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, - modelBuildOptions{}, - ) - require.NoError(t, err) - require.False(t, overrideSet) - require.Nil(t, model) - require.Equal(t, database.ChatModelConfig{}, config) -} - -func TestResolveSummaryGenerationModelOverride_MalformedFallsThrough(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - chat, _ := titleOverrideTestChatAndMessages(t) - - db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("not-a-uuid", nil) - - server := titleOverrideTestServer(db, logger) - config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( - ctx, - chat, - chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, - modelBuildOptions{}, - ) - require.NoError(t, err) - require.False(t, overrideSet) - require.Nil(t, model) - require.Equal(t, database.ChatModelConfig{}, config) -} - -func TestResolveSummaryGenerationModelOverride_SetUsable(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - chat, _ := titleOverrideTestChatAndMessages(t) - overrideConfig := titleOverrideModelConfig("gpt-4.1", true) - - db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) - db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) - db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{Type: database.AIProviderTypeOpenai, Enabled: true}}, nil) - db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() - - server := titleOverrideTestServer(db, logger) - config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( - ctx, - chat, - chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, - modelBuildOptions{}, - ) - require.NoError(t, err) - require.True(t, overrideSet) - require.NotNil(t, model) - require.Equal(t, overrideConfig, config) -} - -func TestResolveSummaryGenerationModelOverride_SetUnusableHardFails(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - chat, _ := titleOverrideTestChatAndMessages(t) - // A disabled config is treated as unavailable. - overrideConfig := titleOverrideModelConfig("gpt-4.1", false) - - db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) - db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) - - server := titleOverrideTestServer(db, logger) - config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( - ctx, - chat, - chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, - modelBuildOptions{}, - ) - // overrideSet is true even on a hard failure so the caller skips generation - // instead of falling back to the chat model. - require.Error(t, err) - require.True(t, overrideSet) - require.ErrorContains(t, err, "summary generation model override is unavailable") - require.Nil(t, model) - require.Equal(t, database.ChatModelConfig{}, config) -} diff --git a/codersdk/chats.go b/codersdk/chats.go index 01d2a5feda0..81809682045 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -738,10 +738,9 @@ type UpdateChatPlanModeInstructionsRequest struct { type ChatModelOverrideContext string const ( - ChatModelOverrideContextGeneral ChatModelOverrideContext = "general" - ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" - ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" - ChatModelOverrideContextSummaryGeneration ChatModelOverrideContext = "summary_generation" + ChatModelOverrideContextGeneral ChatModelOverrideContext = "general" + ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" + ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" ) // Valid reports whether the override context is one of the supported values. @@ -749,8 +748,7 @@ func (c ChatModelOverrideContext) Valid() bool { switch c { case ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, - ChatModelOverrideContextTitleGeneration, - ChatModelOverrideContextSummaryGeneration: + ChatModelOverrideContextTitleGeneration: return true default: return false @@ -763,7 +761,6 @@ func AllChatModelOverrideContexts() []ChatModelOverrideContext { ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, ChatModelOverrideContextTitleGeneration, - ChatModelOverrideContextSummaryGeneration, } } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 11958623012..1c6b538949a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2609,13 +2609,11 @@ export interface ChatModelOpenRouterProviderOptions { export type ChatModelOverrideContext = | "explore" | "general" - | "summary_generation" | "title_generation"; export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [ "explore", "general", - "summary_generation", "title_generation", ]; diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx index ffe681299d8..5f664afb150 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx @@ -20,8 +20,6 @@ const generalOverrideContext: TypesGen.ChatModelOverrideContext = "general"; const exploreOverrideContext: TypesGen.ChatModelOverrideContext = "explore"; const titleGenerationOverrideContext: TypesGen.ChatModelOverrideContext = "title_generation"; -const summaryGenerationOverrideContext: TypesGen.ChatModelOverrideContext = - "summary_generation"; const chatModelOverrideKey = (context: TypesGen.ChatModelOverrideContext) => ["chat-model-override", context] as const; @@ -68,10 +66,6 @@ const AgentSettingsAgentsPage: FC = () => { ...chatModelOverrideQuery(titleGenerationOverrideContext), enabled: canEditDeploymentConfig, }); - const summaryGenerationModelQuery = useQuery({ - ...chatModelOverrideQuery(summaryGenerationOverrideContext), - enabled: canEditDeploymentConfig, - }); const modelConfigsQuery = useQuery(chatModelConfigs()); const savePersonalModelOverridesAdminSettingsMutation = useMutation( updateChatPersonalModelOverridesAdminSettings(queryClient), @@ -85,12 +79,6 @@ const AgentSettingsAgentsPage: FC = () => { titleGenerationOverrideContext, ), ); - const saveSummaryGenerationModelMutation = useMutation( - updateChatModelOverrideMutation( - queryClient, - summaryGenerationOverrideContext, - ), - ); const saveExploreModelOverrideMutation = useMutation( updateChatModelOverrideMutation(queryClient, exploreOverrideContext), ); @@ -135,14 +123,6 @@ const AgentSettingsAgentsPage: FC = () => { isSaveTitleGenerationModelError={ saveTitleGenerationModelMutation.isError } - summaryGenerationModelOverrideData={summaryGenerationModelQuery.data} - onSaveSummaryGenerationModel={saveSummaryGenerationModelMutation.mutate} - isSavingSummaryGenerationModel={ - saveSummaryGenerationModelMutation.isPending - } - isSaveSummaryGenerationModelError={ - saveSummaryGenerationModelMutation.isError - } onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate} isSavingExploreModelOverride={ saveExploreModelOverrideMutation.isPending diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx index c26c7111dea..03bd43ef8f8 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx @@ -42,11 +42,6 @@ const buildTitleGenerationModelOverrideData = ( ): TypesGen.ChatModelOverrideResponse => buildOverrideData("title_generation", overrides); -const buildSummaryGenerationModelOverrideData = ( - overrides: Partial = {}, -): TypesGen.ChatModelOverrideResponse => - buildOverrideData("summary_generation", overrides); - const generalModelConfig = buildModelConfig({ id: "model-general-gpt-4.1-mini", display_name: "GPT 4.1 Mini", @@ -121,7 +116,6 @@ const buildArgs = ( isSaveAdminOverridesError: false, generalModelOverrideData: buildOverrideData("general"), titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData(), - summaryGenerationModelOverrideData: buildSummaryGenerationModelOverrideData(), exploreModelOverrideData: buildOverrideData("explore"), modelConfigsData: allModelConfigs, modelConfigsError: undefined, @@ -132,9 +126,6 @@ const buildArgs = ( onSaveTitleGenerationModel: fn(), isSavingTitleGenerationModel: false, isSaveTitleGenerationModelError: false, - onSaveSummaryGenerationModel: fn(), - isSavingSummaryGenerationModel: false, - isSaveSummaryGenerationModelError: false, onSaveExploreModelOverride: fn(), isSavingExploreModelOverride: false, isSaveExploreModelOverrideError: false, @@ -359,46 +350,6 @@ export const EachOverrideSetToEnabledModel: Story = { }, }; -export const SummaryGenerationModelSetToEnabledModel: Story = { - args: buildArgs({ - summaryGenerationModelOverrideData: buildSummaryGenerationModelOverrideData( - { model_config_id: titleModelConfig.id }, - ), - }), - play: async ({ canvasElement, args }) => { - const summarySection = await getSection( - canvasElement, - "Summary generation model", - ); - - expect( - within(summarySection).getByRole("combobox", { - name: /gpt 4o mini/i, - }), - ).toHaveTextContent("GPT 4o Mini"); - - await selectModelInSection( - summarySection, - canvasElement, - /gpt 4o mini/i, - "Claude Sonnet 4", - ); - const summarySaveButton = within(summarySection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(summarySaveButton).toBeEnabled(); - }); - await userEvent.click(summarySaveButton); - await waitFor(() => { - expect(args.onSaveSummaryGenerationModel).toHaveBeenCalledWith( - { model_config_id: claudeSonnetModelConfig.id }, - expect.anything(), - ); - }); - }, -}; - export const MalformedOverridesRemainClearableAndSaveable: Story = { args: buildArgs({ generalModelOverrideData: buildOverrideData("general", { diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx index 4a69927ce5f..46e2300975e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx @@ -25,7 +25,6 @@ export interface AgentSettingsAgentsPageViewProps { isSaveAdminOverridesError: boolean; generalModelOverrideData?: TypesGen.ChatModelOverrideResponse; titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; - summaryGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse; modelConfigsData: TypesGen.ChatModelConfig[] | undefined; modelConfigsError: unknown; @@ -36,9 +35,6 @@ export interface AgentSettingsAgentsPageViewProps { onSaveTitleGenerationModel: SaveModelOverride; isSavingTitleGenerationModel: boolean; isSaveTitleGenerationModelError: boolean; - onSaveSummaryGenerationModel: SaveModelOverride; - isSavingSummaryGenerationModel: boolean; - isSaveSummaryGenerationModelError: boolean; onSaveExploreModelOverride: SaveModelOverride; isSavingExploreModelOverride: boolean; isSaveExploreModelOverrideError: boolean; @@ -56,7 +52,6 @@ export const AgentSettingsAgentsPageView: FC< isSaveAdminOverridesError, generalModelOverrideData, titleGenerationModelOverrideData, - summaryGenerationModelOverrideData, exploreModelOverrideData, modelConfigsData, modelConfigsError, @@ -67,9 +62,6 @@ export const AgentSettingsAgentsPageView: FC< onSaveTitleGenerationModel, isSavingTitleGenerationModel, isSaveTitleGenerationModelError, - onSaveSummaryGenerationModel, - isSavingSummaryGenerationModel, - isSaveSummaryGenerationModelError, onSaveExploreModelOverride, isSavingExploreModelOverride, isSaveExploreModelOverrideError, @@ -145,31 +137,6 @@ export const AgentSettingsAgentsPageView: FC< showHeader={false} />
-
- - -
( onSaveTitleGenerationModel={fn()} isSavingTitleGenerationModel={false} isSaveTitleGenerationModelError={false} - summaryGenerationModelOverrideData={{ - context: "summary_generation", - model_config_id: "", - is_malformed: false, - }} - onSaveSummaryGenerationModel={fn()} - isSavingSummaryGenerationModel={false} - isSaveSummaryGenerationModelError={false} onSaveExploreModelOverride={fn()} isSavingExploreModelOverride={false} isSaveExploreModelOverrideError={false} From 877bbf0ffbe7435e7125c05c278c60fef9236f03 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 11:54:13 +0000 Subject: [PATCH 06/24] refactor(coderd): defer chat cost accounting to follow-up PR Remove cost_source, UpdateChatMessageCostSource, and summary usage recording from the whole-chat summary feature so summary persistence is not blocked by hidden accounting rows advancing history_version. Title usage recording reverts to main's InsertChatMessages path. Co-authored-by: Cursor --- coderd/database/check_constraint.go | 1 - coderd/database/dbauthz/dbauthz.go | 16 -- coderd/database/dbauthz/dbauthz_test.go | 12 -- coderd/database/dbmetrics/querymetrics.go | 8 - coderd/database/dbmock/dbmock.go | 15 -- coderd/database/dump.sql | 4 +- .../migrations/000534_chat_summary.down.sql | 3 - .../migrations/000534_chat_summary.up.sql | 10 - coderd/database/models.go | 1 - coderd/database/querier.go | 6 - coderd/database/querier_test.go | 80 -------- coderd/database/queries.sql.go | 54 +---- coderd/database/queries/chats.sql | 13 -- coderd/x/chatd/chatd.go | 187 ++++++------------ coderd/x/chatd/chatd_internal_test.go | 8 - 15 files changed, 69 insertions(+), 349 deletions(-) diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 0927a761b2b..76b350816d7 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -21,7 +21,6 @@ const ( CheckAibridgeTokenUsagesOutputPriceMicrosCheck CheckConstraint = "aibridge_token_usages_output_price_micros_check" // aibridge_token_usages CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckBoundaryLogsSequenceNumberCheck CheckConstraint = "boundary_logs_sequence_number_check" // boundary_logs - CheckChatMessagesCostSourceCheck CheckConstraint = "chat_messages_cost_source_check" // chat_messages CheckChatModelConfigsAIProviderRequiredWhenActive CheckConstraint = "chat_model_configs_ai_provider_required_when_active" // chat_model_configs CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f46c9234cf4..bbbe23c254a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7214,22 +7214,6 @@ func (q *querier) UpdateChatMessageByID(ctx context.Context, arg database.Update return q.db.UpdateChatMessageByID(ctx, arg) } -func (q *querier) UpdateChatMessageCostSource(ctx context.Context, arg database.UpdateChatMessageCostSourceParams) (int64, error) { - // Authorize update on the parent chat of the tagged message. - msg, err := q.db.GetChatMessageByID(ctx, arg.ID) - if err != nil { - return 0, err - } - chat, err := q.db.GetChatByID(ctx, msg.ChatID) - if err != nil { - return 0, err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return 0, err - } - return q.db.UpdateChatMessageCostSource(ctx, arg) -} - func (q *querier) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return database.ChatModelConfig{}, err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 19d658aefee..a5757923844 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1511,18 +1511,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatMessageByID(gomock.Any(), arg).Return(updated, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(updated) })) - s.Run("UpdateChatMessageCostSource", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - chat := testutil.Fake(s.T(), faker, database.Chat{}) - msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) - arg := database.UpdateChatMessageCostSourceParams{ - ID: msg.ID, - CostSource: "summary", - } - dbm.EXPECT().GetChatMessageByID(gomock.Any(), msg.ID).Return(msg, nil).AnyTimes() - dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().UpdateChatMessageCostSource(gomock.Any(), arg).Return(int64(1), nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1)) - })) s.Run("UpdateChatModelConfig", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { config := testutil.Fake(s.T(), faker, database.ChatModelConfig{}) arg := database.UpdateChatModelConfigParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 9798c1a2cb1..11ae8e0bbb3 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5178,14 +5178,6 @@ func (m queryMetricsStore) UpdateChatMessageByID(ctx context.Context, arg databa return r0, r1 } -func (m queryMetricsStore) UpdateChatMessageCostSource(ctx context.Context, arg database.UpdateChatMessageCostSourceParams) (int64, error) { - start := time.Now() - r0, r1 := m.s.UpdateChatMessageCostSource(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateChatMessageCostSource").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatMessageCostSource").Inc() - return r0, r1 -} - func (m queryMetricsStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { start := time.Now() r0, r1 := m.s.UpdateChatModelConfig(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 62e9b132b08..f846a46bf45 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9755,21 +9755,6 @@ func (mr *MockStoreMockRecorder) UpdateChatMessageByID(ctx, arg any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMessageByID", reflect.TypeOf((*MockStore)(nil).UpdateChatMessageByID), ctx, arg) } -// UpdateChatMessageCostSource mocks base method. -func (m *MockStore) UpdateChatMessageCostSource(ctx context.Context, arg database.UpdateChatMessageCostSourceParams) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateChatMessageCostSource", ctx, arg) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// UpdateChatMessageCostSource indicates an expected call of UpdateChatMessageCostSource. -func (mr *MockStoreMockRecorder) UpdateChatMessageCostSource(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatMessageCostSource", reflect.TypeOf((*MockStore)(nil).UpdateChatMessageCostSource), ctx, arg) -} - // UpdateChatModelConfig mocks base method. func (m *MockStore) UpdateChatModelConfig(ctx context.Context, arg database.UpdateChatModelConfigParams) (database.ChatModelConfig, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b6139cc8725..a208ef4234a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1895,9 +1895,7 @@ CREATE TABLE chat_messages ( deleted boolean DEFAULT false NOT NULL, provider_response_id text, api_key_id text, - revision bigint NOT NULL, - cost_source text, - CONSTRAINT chat_messages_cost_source_check CHECK ((cost_source = ANY (ARRAY['summary'::text, 'title'::text]))) + revision bigint NOT NULL ); CREATE SEQUENCE chat_messages_id_seq diff --git a/coderd/database/migrations/000534_chat_summary.down.sql b/coderd/database/migrations/000534_chat_summary.down.sql index 47be6f25eba..dda181ed92d 100644 --- a/coderd/database/migrations/000534_chat_summary.down.sql +++ b/coderd/database/migrations/000534_chat_summary.down.sql @@ -6,9 +6,6 @@ ALTER TABLE chats DROP COLUMN summary, DROP COLUMN summary_generated_at; -ALTER TABLE chat_messages - DROP COLUMN cost_source; - CREATE VIEW chats_expanded AS SELECT c.id, c.owner_id, diff --git a/coderd/database/migrations/000534_chat_summary.up.sql b/coderd/database/migrations/000534_chat_summary.up.sql index 0aca7f0e3f1..3c57bd9eb6d 100644 --- a/coderd/database/migrations/000534_chat_summary.up.sql +++ b/coderd/database/migrations/000534_chat_summary.up.sql @@ -5,16 +5,6 @@ ALTER TABLE chats ADD COLUMN summary TEXT, ADD COLUMN summary_generated_at TIMESTAMPTZ; --- cost_source attributes spend on a chat_message to a specific feature so that --- summary and title generation spend can be reported separately from ordinary --- turn spend. NULL means ordinary turn spend; 'summary' and 'title' tag the --- hidden accounting rows written for background summary and manual title --- generation respectively. The CHECK constrains it to the closed discriminator --- set so a typo or direct SQL write cannot silently corrupt cost attribution --- (NULL is implicitly allowed). -ALTER TABLE chat_messages - ADD COLUMN cost_source TEXT CHECK (cost_source IN ('summary', 'title')); - -- Recreate chats_expanded so the new chat columns are exposed to the API. The -- view has an explicit column list, so new columns are otherwise invisible. -- summary and summary_generated_at are placed next to last_turn_summary. diff --git a/coderd/database/models.go b/coderd/database/models.go index c7114b469d5..7f00e0f2ce6 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4951,7 +4951,6 @@ type ChatMessage struct { ProviderResponseID sql.NullString `db:"provider_response_id" json:"provider_response_id"` APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` Revision int64 `db:"revision" json:"revision"` - CostSource sql.NullString `db:"cost_source" json:"cost_source"` } type ChatModelConfig struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 10de37dcb91..184228605aa 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1367,12 +1367,6 @@ type sqlcQuerier interface { UpdateChatLastTurnSummary(ctx context.Context, arg UpdateChatLastTurnSummaryParams) (int64, error) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatMCPServerIDsParams) (Chat, error) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error) - // Tags a chat_message with a cost_source so its spend is attributable to a - // specific feature (for example 'summary' or 'title') rather than ordinary - // turn spend. Used to mark the hidden accounting rows written for background - // summary and manual title generation without threading a new field through - // the shared InsertChatMessages batch insert. - UpdateChatMessageCostSource(ctx context.Context, arg UpdateChatMessageCostSourceParams) (int64, error) UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index d38401223f0..a35c1e771ef 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12603,86 +12603,6 @@ func TestUpdateChatSummary(t *testing.T) { require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) } -func TestUpdateChatMessageCostSource(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}) - - dbgen.ChatProvider(t, db, database.ChatProvider{ - Provider: "openai", - DisplayName: "OpenAI", - APIKey: "test-key", - Enabled: true, - CentralApiKeyEnabled: true, - }) - modelCfg, err := insertChatModelConfigForTest(ctx, t, db, 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: "cost-source-chat", - }) - require.NoError(t, err) - - msg := dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: chat.ID, - CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, - ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true}, - Role: database.ChatMessageRoleAssistant, - Visibility: database.ChatMessageVisibilityModel, - }) - require.False(t, msg.CostSource.Valid) - - affected, err := db.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ - ID: msg.ID, - CostSource: "summary", - }) - require.NoError(t, err) - require.EqualValues(t, 1, affected) - - fetched, err := db.GetChatMessageByID(ctx, msg.ID) - require.NoError(t, err) - require.Equal(t, sql.NullString{String: "summary", Valid: true}, fetched.CostSource) - - // Empty cost source clears the discriminator back to NULL (ordinary spend). - affected, err = db.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ - ID: msg.ID, - CostSource: "", - }) - require.NoError(t, err) - require.EqualValues(t, 1, affected) - - fetched, err = db.GetChatMessageByID(ctx, msg.ID) - require.NoError(t, err) - require.False(t, fetched.CostSource.Valid) -} - func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2d9a276e050..904536e1a8f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7477,7 +7477,7 @@ func (q *sqlQuerier) GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatP const getChatMessageByID = `-- name: GetChatMessageByID :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -7512,7 +7512,6 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ) return i, err } @@ -7602,7 +7601,7 @@ func (q *sqlQuerier) GetChatMessageSummariesPerChat(ctx context.Context, created const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -7652,7 +7651,6 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ); err != nil { return nil, err } @@ -7669,7 +7667,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes const getChatMessagesByChatIDAscPaginated = `-- name: GetChatMessagesByChatIDAscPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -7722,7 +7720,6 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ); err != nil { return nil, err } @@ -7739,7 +7736,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar const getChatMessagesByChatIDDescPaginated = `-- name: GetChatMessagesByChatIDDescPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -7805,7 +7802,6 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ); err != nil { return nil, err } @@ -7822,7 +7818,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a const getChatMessagesByRevisionForStream = `-- name: GetChatMessagesByRevisionForStream :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -7871,7 +7867,6 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ); err != nil { return nil, err } @@ -7904,7 +7899,7 @@ WITH latest_compressed_summary AS ( 1 ) SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -7978,7 +7973,6 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ); err != nil { return nil, err } @@ -9225,7 +9219,7 @@ func (q *sqlQuerier) GetDatabaseNow(ctx context.Context) (time.Time, error) { const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision FROM chat_messages WHERE @@ -9270,7 +9264,6 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ) return i, err } @@ -9778,7 +9771,7 @@ SELECT NULLIF(UNNEST($18::bigint[]), 0), NULLIF(UNNEST($19::text[]), '') RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision ` type InsertChatMessagesParams struct { @@ -9856,7 +9849,6 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ); err != nil { return nil, err } @@ -11715,7 +11707,7 @@ SET WHERE id = $3::bigint RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, cost_source + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision ` type UpdateChatMessageByIDParams struct { @@ -11751,38 +11743,10 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ProviderResponseID, &i.APIKeyID, &i.Revision, - &i.CostSource, ) return i, err } -const updateChatMessageCostSource = `-- name: UpdateChatMessageCostSource :execrows -UPDATE - chat_messages -SET - cost_source = NULLIF($1::text, '') -WHERE - id = $2::bigint -` - -type UpdateChatMessageCostSourceParams struct { - CostSource string `db:"cost_source" json:"cost_source"` - ID int64 `db:"id" json:"id"` -} - -// Tags a chat_message with a cost_source so its spend is attributable to a -// specific feature (for example 'summary' or 'title') rather than ordinary -// turn spend. Used to mark the hidden accounting rows written for background -// summary and manual title generation without threading a new field through -// the shared InsertChatMessages batch insert. -func (q *sqlQuerier) UpdateChatMessageCostSource(ctx context.Context, arg UpdateChatMessageCostSourceParams) (int64, error) { - result, err := q.db.ExecContext(ctx, updateChatMessageCostSource, arg.CostSource, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - const updateChatPinOrder = `-- name: UpdateChatPinOrder :exec WITH target_chat AS ( SELECT diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 1a5324cb303..b6efa38b8ce 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -879,19 +879,6 @@ WHERE RETURNING *; --- name: UpdateChatMessageCostSource :execrows --- Tags a chat_message with a cost_source so its spend is attributable to a --- specific feature (for example 'summary' or 'title') rather than ordinary --- turn spend. Used to mark the hidden accounting rows written for background --- summary and manual title generation without threading a new field through --- the shared InsertChatMessages batch insert. -UPDATE - chat_messages -SET - cost_source = NULLIF(@cost_source::text, '') -WHERE - id = @id::bigint; - -- name: UpdateChatByID :one WITH updated_chat AS ( UPDATE diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 933cdf8e4d8..1e306f4a9dc 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2887,82 +2887,6 @@ func fantasyUsageToChatMessageUsage(usage fantasy.Usage) codersdk.ChatMessageUsa return chatUsage } -// recordHiddenUsageMessageTx records non-turn spend (background summary or -// manual title generation) as a hidden, soft-deleted accounting message tagged -// with costSource, then restores the chat's last model config. It runs inside -// the caller's transaction against the already-locked chat and does not touch -// any user-visible chat field. costSource also labels the wrapped errors. -func recordHiddenUsageMessageTx( - ctx context.Context, - tx database.Store, - lockedChat database.Chat, - modelConfig database.ChatModelConfig, - usage fantasy.Usage, - activeAPIKeyID string, - costSource string, -) error { - callConfig := codersdk.ChatModelCallConfig{} - if len(modelConfig.Options) > 0 { - if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { - return xerrors.Errorf("parse model call config: %w", err) - } - } - totalCostMicros := chatcost.CalculateTotalCostMicros( - fantasyUsageToChatMessageUsage(usage), - callConfig.Cost, - ) - - // MarshalParts returns a null NullRawMessage for empty slices, which becomes - // an empty string that PostgreSQL rejects as invalid JSON. - content := "[]" - - messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{ - ChatID: lockedChat.ID, - CreatedBy: []uuid.UUID{lockedChat.OwnerID}, - APIKeyID: []string{activeAPIKeyID}, - ModelConfigID: []uuid.UUID{modelConfig.ID}, - Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, - Content: []string{content}, - ContentVersion: []int16{chatprompt.CurrentContentVersion}, - Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel}, - InputTokens: []int64{usage.InputTokens}, - OutputTokens: []int64{usage.OutputTokens}, - TotalTokens: []int64{usage.TotalTokens}, - ReasoningTokens: []int64{usage.ReasoningTokens}, - CacheCreationTokens: []int64{usage.CacheCreationTokens}, - CacheReadTokens: []int64{usage.CacheReadTokens}, - ContextLimit: []int64{modelConfig.ContextLimit}, - Compressed: []bool{false}, - TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)}, - RuntimeMs: []int64{0}, - ProviderResponseID: []string{""}, - }) - if err != nil { - return xerrors.Errorf("insert %s usage message: %w", costSource, err) - } - if len(messages) != 1 { - return xerrors.Errorf("expected 1 %s usage message, got %d", costSource, len(messages)) - } - if _, err := tx.UpdateChatMessageCostSource(ctx, database.UpdateChatMessageCostSourceParams{ - ID: messages[0].ID, - CostSource: costSource, - }); err != nil { - return xerrors.Errorf("tag %s usage cost source: %w", costSource, err) - } - if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { - return xerrors.Errorf("soft delete %s usage message: %w", costSource, err) - } - if lockedChat.LastModelConfigID != modelConfig.ID { - if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{ - ID: lockedChat.ID, - LastModelConfigID: lockedChat.LastModelConfigID, - }); err != nil { - return xerrors.Errorf("restore chat model config after %s usage: %w", costSource, err) - } - } - return nil -} - func recordManualTitleUsage( ctx context.Context, store database.Store, @@ -2977,6 +2901,26 @@ func recordManualTitleUsage( return chat, nil } + var totalCostMicros *int64 + if hasUsage { + callConfig := codersdk.ChatModelCallConfig{} + if len(modelConfig.Options) > 0 { + if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { + return database.Chat{}, xerrors.Errorf("parse model call config: %w", err) + } + } + totalCostMicros = chatcost.CalculateTotalCostMicros( + fantasyUsageToChatMessageUsage(usage), + callConfig.Cost, + ) + } + + // Use a valid empty JSON array for the content column. + // MarshalParts returns a null NullRawMessage for empty + // slices, which becomes an empty string that PostgreSQL + // rejects as invalid JSON. + content := "[]" + updatedChat := chat err := store.InTx(func(tx database.Store) error { lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID) @@ -2985,8 +2929,43 @@ func recordManualTitleUsage( } updatedChat = lockedChat if hasUsage { - if err := recordHiddenUsageMessageTx(ctx, tx, lockedChat, modelConfig, usage, activeAPIKeyID, chatCostSourceTitle); err != nil { - return err + messages, err := tx.InsertChatMessages(ctx, database.InsertChatMessagesParams{ + ChatID: chat.ID, + CreatedBy: []uuid.UUID{chat.OwnerID}, + APIKeyID: []string{activeAPIKeyID}, + ModelConfigID: []uuid.UUID{modelConfig.ID}, + Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, + Content: []string{content}, + ContentVersion: []int16{chatprompt.CurrentContentVersion}, + Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityModel}, + InputTokens: []int64{usage.InputTokens}, + OutputTokens: []int64{usage.OutputTokens}, + TotalTokens: []int64{usage.TotalTokens}, + ReasoningTokens: []int64{usage.ReasoningTokens}, + CacheCreationTokens: []int64{usage.CacheCreationTokens}, + CacheReadTokens: []int64{usage.CacheReadTokens}, + ContextLimit: []int64{modelConfig.ContextLimit}, + Compressed: []bool{false}, + TotalCostMicros: []int64{ptr.NilToDefault(totalCostMicros, 0)}, + RuntimeMs: []int64{0}, + ProviderResponseID: []string{""}, + }) + if err != nil { + return xerrors.Errorf("insert manual title usage message: %w", err) + } + if len(messages) != 1 { + return xerrors.Errorf("expected 1 manual title usage message, got %d", len(messages)) + } + if err := tx.SoftDeleteChatMessageByID(ctx, messages[0].ID); err != nil { + return xerrors.Errorf("soft delete manual title usage message: %w", err) + } + if lockedChat.LastModelConfigID != modelConfig.ID { + if _, err := tx.UpdateChatLastModelConfigByID(ctx, database.UpdateChatLastModelConfigByIDParams{ + ID: chat.ID, + LastModelConfigID: lockedChat.LastModelConfigID, + }); err != nil { + return xerrors.Errorf("restore chat model config after manual title usage: %w", err) + } } } if newTitle != "" && lockedChat.Title == chat.Title && newTitle != lockedChat.Title { @@ -4936,13 +4915,6 @@ const ( chatSummaryWriteTimeout = 5 * time.Second ) -// chatCostSource values tag hidden accounting rows so non-turn spend is -// attributable per feature in cost reporting. Ordinary turn spend is left NULL. -const ( - chatCostSourceSummary = "summary" - chatCostSourceTitle = "title" -) - // maybeGenerateChatSummaryAsync launches background whole-chat summary // generation after a successful turn on a root chat. It is best-effort: it runs // detached from the request context so the user's turn is never blocked, and @@ -5016,24 +4988,14 @@ func (p *Server) generateAndStoreChatSummary( return } - model, modelConfig, ok := p.resolveChatSummaryModel(authCtx, chat, runResult, logger) + model, _, ok := p.resolveChatSummaryModel(authCtx, chat, runResult, logger) if !ok { return } summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) defer cancelGen() - summary, usage, genErr := generateChatSummary(summaryCtx, model, transcript) - - // Record cost whenever the model reported usage, even on failure, so spend - // is attributed. The active API key is best-effort from the latest turn. - if usage != (fantasy.Usage{}) { - activeAPIKeyID, _ := activeTurnAPIKeyIDFromMessages(messages) - if _, recordErr := recordChatSummaryUsage(authCtx, p.db, chat, modelConfig, usage, activeAPIKeyID); recordErr != nil { - logger.Warn(ctx, "failed to record chat summary usage", - slog.F("chat_id", chat.ID), slog.Error(recordErr)) - } - } + summary, _, genErr := generateChatSummary(summaryCtx, model, transcript) if genErr != nil { logger.Debug(ctx, "failed to generate chat summary", @@ -5144,37 +5106,6 @@ func (p *Server) updateChatSummary( p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) } -// recordChatSummaryUsage records the cost of a background summary generation as -// a hidden, soft-deleted accounting row tagged with cost_source='summary'. It -// locks the chat and delegates to recordHiddenUsageMessageTx; unlike -// recordManualTitleUsage it never updates the chat title. -func recordChatSummaryUsage( - ctx context.Context, - store database.Store, - chat database.Chat, - modelConfig database.ChatModelConfig, - usage fantasy.Usage, - activeAPIKeyID string, -) (database.Chat, error) { - if usage == (fantasy.Usage{}) { - return chat, nil - } - - updatedChat := chat - err := store.InTx(func(tx database.Store) error { - lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID) - if err != nil { - return xerrors.Errorf("lock chat for summary usage: %w", err) - } - updatedChat = lockedChat - return recordHiddenUsageMessageTx(ctx, tx, lockedChat, modelConfig, usage, activeAPIKeyID, chatCostSourceSummary) - }, nil) - if err != nil { - return database.Chat{}, err - } - return updatedChat, nil -} - func (p *Server) webpushConfigured() bool { return p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != "" } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 7289988c7f1..3c15609a3c4 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -932,10 +932,6 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { return []database.ChatMessage{{ID: 91}}, nil }, ) - usageTx.EXPECT().UpdateChatMessageCostSource(gomock.Any(), database.UpdateChatMessageCostSourceParams{ - ID: 91, - CostSource: "title", - }).Return(int64(1), nil) usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ ID: chatID, @@ -1116,10 +1112,6 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t return []database.ChatMessage{{ID: 91}}, nil }, ) - usageTx.EXPECT().UpdateChatMessageCostSource(gomock.Any(), database.UpdateChatMessageCostSourceParams{ - ID: 91, - CostSource: "title", - }).Return(int64(1), nil) usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ ID: chatID, From 7a57ac09c54bed0fae6b45ddcf95c56b76af7356 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 12:42:26 +0000 Subject: [PATCH 07/24] fix(coderd/x/chatd): close chat summary history-version and shutdown races Addresses two issues from the codex review of background whole-chat summary generation: - Read the chat (and its history_version) before loading the transcript so a turn committing between the two reads leaves the captured history_version behind the transcript. UpdateChatSummary then rejects the write instead of persisting a summary that omits the just-committed turn and advancing the cadence marker past it. - Launch the background summary goroutine through goInflight instead of p.inflight.Go. goInflight serializes the WaitGroup Add with drainInflight under inflightMu and drops the launch once shutdown has begun, matching the sibling finalize and last-turn-summary helpers. The previous direct Add raced drainInflight's Wait and could delay Close. --- coderd/x/chatd/chatd.go | 46 ++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 1e306f4a9dc..9a4a4c01aa3 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4928,17 +4928,19 @@ func (p *Server) maybeGenerateChatSummaryAsync( if chat.ParentChatID.Valid { return } - // Launch background summary generation tracked by p.inflight so Close() - // waits for it. Bail out early if shutdown has begun: generation can run for - // up to chatSummaryWorkTimeout, and Close() must not block that long. The - // atomic inflightClosed check reproduces goInflight's shutdown admission - // gate without taking inflightMu, which drainInflight holds while waiting. - p.inflight.Go(func() { - if p.inflightClosed.Load() { - return - } + // Launch background summary generation through goInflight so Close() waits + // for it and the WaitGroup Add is serialized with drainInflight under + // inflightMu, matching the sibling finalize and last-turn-summary helpers. A + // direct p.inflight.Go would Add to the WaitGroup outside inflightMu and + // before the shutdown check, which can race drainInflight's Wait. goInflight + // instead drops the launch once shutdown has begun, so Close() is never + // blocked for up to chatSummaryWorkTimeout by a turn finishing concurrently. + if err := p.goInflight(func() { p.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, runResult, logger) - }) + }); err != nil { + logger.Debug(context.WithoutCancel(ctx), "skipped chat summary generation", + slog.F("chat_id", chat.ID), slog.Error(err)) + } } // generateAndStoreChatSummary regenerates the whole-chat summary when the @@ -4956,21 +4958,27 @@ func (p *Server) generateAndStoreChatSummary( //nolint:gocritic // Narrow daemon access for best-effort summary generation. authCtx := dbauthz.AsChatd(ctx) - messages, err := p.db.GetChatMessagesForPromptByChatID(authCtx, chat.ID) + // Re-read the chat before loading the transcript so the captured + // history_version is no newer than the messages it guards. The chat passed + // in is a snapshot from when the turn finished; reading it fresh also lets + // the cadence gate see the latest Summary and SummaryGeneratedAt, so two + // rapid back-to-back turns do not both pass the gate and call the LLM. + // + // Ordering matters for correctness: if a new turn commits between these two + // reads, the captured history_version stays behind the transcript, so the + // UpdateChatSummary guard rejects the write rather than persisting a summary + // that omits the just-committed turn and advancing the cadence marker past + // it. + chat, err := p.db.GetChatByID(authCtx, chat.ID) if err != nil { - logger.Debug(ctx, "failed to load messages for chat summary", + logger.Debug(ctx, "failed to re-read chat for summary", slog.F("chat_id", chat.ID), slog.Error(err)) return } - // Re-read the chat so the cadence gate sees the freshest Summary and - // SummaryGeneratedAt. The chat passed in is a snapshot from when the turn - // finished. Without this, two rapid back-to-back turns both see the same - // stale snapshot, both pass the gate, and both call the LLM, even though the - // history_version guard in UpdateChatSummary still persists only one. - chat, err = p.db.GetChatByID(authCtx, chat.ID) + messages, err := p.db.GetChatMessagesForPromptByChatID(authCtx, chat.ID) if err != nil { - logger.Debug(ctx, "failed to re-read chat for summary", + logger.Debug(ctx, "failed to load messages for chat summary", slog.F("chat_id", chat.ID), slog.Error(err)) return } From 985095809531fb2922bc5762747390ad5a6190c8 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 14:50:38 +0000 Subject: [PATCH 08/24] fix(coderd/x/chatd): derive summary model options from the loaded transcript The detached background summary goroutine re-reads the chat and transcript fresh, but resolveChatSummaryModel still used the ModelBuildOptions captured from the turn that launched the goroutine. In AI Gateway deployments that ActiveAPIKeyID can be stale or belong to a different turn once a later turn commits first, so the summary call could fail with a missing key or be attributed to the wrong key. Derive modelBuildOptionsFromMessages(messages) after loading the transcript and thread the active turn API key onto the context, mirroring deriveFinalTurnRunResult and the generation path. With this, runResult is no longer needed by the summary helpers, so drop the parameter. Also fix the generateAndStoreChatSummary doc comment (cost recording is deferred to #26689) and add a shouldGenerateChatSummary subtest that pins the countCompletedTurnsSince time-filter guard against pre-marker turns. --- coderd/x/chatd/chatd.go | 25 ++++++++++++++-------- coderd/x/chatd/summarygen_internal_test.go | 19 ++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9a4a4c01aa3..1c73a9a6607 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4669,7 +4669,7 @@ func (p *Server) maybeFinalizeTurnStatusLabelAndPush( switch status { case database.ChatStatusWaiting: p.finalizeSuccessfulTurnStatusLabelAndPush(ctx, chat, status, runResult, logger) - p.maybeGenerateChatSummaryAsync(ctx, chat, runResult, logger) + p.maybeGenerateChatSummaryAsync(ctx, chat, logger) case database.ChatStatusPending: p.setLastTurnSummaryAsync(ctx, chat, fallbackTurnStatusLabel(status), logger) @@ -4922,7 +4922,6 @@ const ( func (p *Server) maybeGenerateChatSummaryAsync( ctx context.Context, chat database.Chat, - runResult runChatResult, logger slog.Logger, ) { if chat.ParentChatID.Valid { @@ -4936,7 +4935,7 @@ func (p *Server) maybeGenerateChatSummaryAsync( // instead drops the launch once shutdown has begun, so Close() is never // blocked for up to chatSummaryWorkTimeout by a turn finishing concurrently. if err := p.goInflight(func() { - p.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, runResult, logger) + p.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, logger) }); err != nil { logger.Debug(context.WithoutCancel(ctx), "skipped chat summary generation", slog.F("chat_id", chat.ID), slog.Error(err)) @@ -4944,12 +4943,11 @@ func (p *Server) maybeGenerateChatSummaryAsync( } // generateAndStoreChatSummary regenerates the whole-chat summary when the -// cadence gate allows, then stores it and records its cost. It is best-effort -// and never clears an existing summary on failure. +// cadence gate allows, then stores it. It is best-effort and never clears an +// existing summary on failure. func (p *Server) generateAndStoreChatSummary( ctx context.Context, chat database.Chat, - runResult runChatResult, logger slog.Logger, ) { ctx, cancel := context.WithTimeout(ctx, chatSummaryWorkTimeout) @@ -4996,7 +4994,16 @@ func (p *Server) generateAndStoreChatSummary( return } - model, _, ok := p.resolveChatSummaryModel(authCtx, chat, runResult, logger) + // Derive the model build options from the transcript just loaded rather + // than from the turn that launched this goroutine. A detached summary + // goroutine can run after a later turn has committed, so the launching + // turn's options (notably ActiveAPIKeyID, required for AI Gateway routing) + // may be stale or belong to a different turn. Thread the key onto the + // context too, matching deriveFinalTurnRunResult and the generation path. + modelOpts := modelBuildOptionsFromMessages(messages) + authCtx = withActiveTurnAPIKeyID(authCtx, modelOpts) + + model, _, ok := p.resolveChatSummaryModel(authCtx, chat, modelOpts, logger) if !ok { return } @@ -5019,11 +5026,11 @@ func (p *Server) generateAndStoreChatSummary( func (p *Server) resolveChatSummaryModel( ctx context.Context, chat database.Chat, - runResult runChatResult, + modelOpts modelBuildOptions, logger slog.Logger, ) (fantasy.LanguageModel, database.ChatModelConfig, bool) { //nolint:dogsled // resolveChatModel returns rich routing metadata; summary generation only needs the model and its config. - model, dbConfig, _, _, _, _, _, err := p.resolveChatModel(ctx, chat, runResult.ModelBuildOptions) + model, dbConfig, _, _, _, _, _, err := p.resolveChatModel(ctx, chat, modelOpts) if err != nil { logger.Debug(ctx, "failed to resolve chat model for summary", slog.F("chat_id", chat.ID), slog.Error(err)) diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index ac82cc36935..d6b5ae08a03 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -211,6 +211,25 @@ func TestShouldGenerateChatSummary(t *testing.T) { } require.True(t, shouldGenerateChatSummary(chat, msgs)) }) + + t.Run("PreMarkerTurnsAreNotCounted", func(t *testing.T) { + t.Parallel() + marker := base + chat := database.Chat{ + Summary: sql.NullString{String: "existing", Valid: true}, + SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true}, + } + // Two post-marker turns stay below the refresh threshold of 3. The + // pre-marker turn would tip the total to 3, so this stays false only + // because countCompletedTurnsSince excludes turns at or before the + // marker. Dropping that time filter would flip this to true. + msgs := []database.ChatMessage{ + userMsg(1, marker.Add(-time.Minute)), + userMsg(2, marker.Add(time.Minute)), + userMsg(3, marker.Add(2*time.Minute)), + } + require.False(t, shouldGenerateChatSummary(chat, msgs)) + }) } func TestValidateGeneratedChatSummary(t *testing.T) { From 794c427950aabb614b98ef888045becf51e79214 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 30 Jun 2026 05:14:51 +0000 Subject: [PATCH 09/24] docs(coderd/x/chatd): trim verbose comments from chat summary feature Remove comments that restate the code or a symbol's name and shorten the remaining ones, keeping only the non-obvious rationale: the history_version read ordering, the goInflight shutdown gating, deriving model options from the fresh transcript, the summary_change vs chat_summary_change distinction, and the SQL staleness guard. Regenerated query, API doc, and type artifacts for the trimmed UpdateChatSummary query and Summary field comments. --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- .../migrations/000534_chat_summary.up.sql | 9 +- coderd/database/querier.go | 12 +-- coderd/database/queries.sql.go | 12 +-- coderd/database/queries/chats.sql | 12 +-- coderd/x/chatd/chatd.go | 90 ++++++------------- coderd/x/chatd/generation_model_override.go | 10 +-- coderd/x/chatd/quickgen.go | 48 ++++------ coderd/x/chatd/summarygen_internal_test.go | 30 ++----- codersdk/chats.go | 11 ++- docs/reference/api/chats.md | 2 +- docs/reference/api/schemas.md | 2 +- site/src/api/queries/chats.ts | 9 +- site/src/api/typesGenerated.ts | 4 +- 15 files changed, 88 insertions(+), 167 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e40acc93de2..78ac710ddf1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16726,7 +16726,7 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatStatus" }, "summary": { - "description": "Summary is the persisted whole-chat summary, generated asynchronously in\nthe background. It is nil until the first summary has been produced.", + "description": "Summary is the persisted whole-chat summary, generated in the background.\nIt is nil until the first summary has been produced.", "type": "string" }, "title": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 36431abea5a..14110483898 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15030,7 +15030,7 @@ "$ref": "#/definitions/codersdk.ChatStatus" }, "summary": { - "description": "Summary is the persisted whole-chat summary, generated asynchronously in\nthe background. It is nil until the first summary has been produced.", + "description": "Summary is the persisted whole-chat summary, generated in the background.\nIt is nil until the first summary has been produced.", "type": "string" }, "title": { diff --git a/coderd/database/migrations/000534_chat_summary.up.sql b/coderd/database/migrations/000534_chat_summary.up.sql index 3c57bd9eb6d..ea8e9aad8dc 100644 --- a/coderd/database/migrations/000534_chat_summary.up.sql +++ b/coderd/database/migrations/000534_chat_summary.up.sql @@ -1,13 +1,10 @@ --- Add the persisted whole-chat summary and its freshness marker. The summary --- is generated in the background after successful turns and is distinct from --- last_turn_summary, which only reflects the most recent turn. +-- Persisted whole-chat summary and its freshness marker, distinct from +-- last_turn_summary (which only reflects the most recent turn). ALTER TABLE chats ADD COLUMN summary TEXT, ADD COLUMN summary_generated_at TIMESTAMPTZ; --- Recreate chats_expanded so the new chat columns are exposed to the API. The --- view has an explicit column list, so new columns are otherwise invisible. --- summary and summary_generated_at are placed next to last_turn_summary. +-- Recreate chats_expanded: its explicit column list hides new columns otherwise. DROP VIEW IF EXISTS chats_expanded; CREATE VIEW chats_expanded AS diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 184228605aa..b49e98c8d88 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1375,14 +1375,10 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error) - // Updates the persisted whole-chat summary shown in the chat summary popover. - // Empty or whitespace-only summaries are stored as NULL so callers cannot - // accidentally persist blank text. summary_generated_at records when the - // summary was produced and drives the background regeneration cadence. - // This intentionally preserves updated_at. The staleness guard uses - // history_version, mirroring UpdateChatLastTurnSummary, so background writes - // racing a newer durable history change lose while worker lifecycle - // transitions that do not change message history cannot reject a fresh write. + // Stores blank summaries as NULL. summary_generated_at drives the regeneration + // cadence. The staleness guard is history_version (not updated_at, which is + // preserved), mirroring UpdateChatLastTurnSummary: a background write racing a + // newer message change loses, but worker transitions cannot reject a fresh write. UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 904536e1a8f..00441c7f682 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12367,14 +12367,10 @@ type UpdateChatSummaryParams struct { ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` } -// Updates the persisted whole-chat summary shown in the chat summary popover. -// Empty or whitespace-only summaries are stored as NULL so callers cannot -// accidentally persist blank text. summary_generated_at records when the -// summary was produced and drives the background regeneration cadence. -// This intentionally preserves updated_at. The staleness guard uses -// history_version, mirroring UpdateChatLastTurnSummary, so background writes -// racing a newer durable history change lose while worker lifecycle -// transitions that do not change message history cannot reject a fresh write. +// Stores blank summaries as NULL. summary_generated_at drives the regeneration +// cadence. The staleness guard is history_version (not updated_at, which is +// preserved), mirroring UpdateChatLastTurnSummary: a background write racing a +// newer message change loses, but worker transitions cannot reject a fresh write. func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, arg.ID, arg.ExpectedHistoryVersion) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index b6efa38b8ce..a3e6666b956 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1359,14 +1359,10 @@ WHERE AND history_version = @expected_history_version::bigint; -- name: UpdateChatSummary :execrows --- Updates the persisted whole-chat summary shown in the chat summary popover. --- Empty or whitespace-only summaries are stored as NULL so callers cannot --- accidentally persist blank text. summary_generated_at records when the --- summary was produced and drives the background regeneration cadence. --- This intentionally preserves updated_at. The staleness guard uses --- history_version, mirroring UpdateChatLastTurnSummary, so background writes --- racing a newer durable history change lose while worker lifecycle --- transitions that do not change message history cannot reject a fresh write. +-- Stores blank summaries as NULL. summary_generated_at drives the regeneration +-- cadence. The staleness guard is history_version (not updated_at, which is +-- preserved), mirroring UpdateChatLastTurnSummary: a background write racing a +-- newer message change loses, but worker transitions cannot reject a fresh write. UPDATE chats SET summary = NULLIF(REGEXP_REPLACE( diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 1c73a9a6607..7f29ba9092c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4894,31 +4894,19 @@ func (p *Server) updateLastTurnSummary( } const ( - // summaryFirstTurnThreshold is the minimum number of completed turns before - // the first whole-chat summary is generated. - summaryFirstTurnThreshold = 1 - // summaryRefreshTurnThreshold is the number of completed turns since the - // last summary before it is regenerated. It is a tunable cadence knob that - // bounds eager LLM spend. + // Cadence gate bounding LLM spend: turns before the first summary, then + // turns between refreshes. + summaryFirstTurnThreshold = 1 summaryRefreshTurnThreshold = 3 - // summaryMinTranscriptRunes skips summary generation for very short chats - // where the title already conveys the content. - summaryMinTranscriptRunes = 200 - // chatSummaryWorkTimeout bounds the whole background summary operation - // (load, model resolution, generation, persistence). - chatSummaryWorkTimeout = 120 * time.Second - // chatSummaryGenerateTimeout bounds the model call itself. + // Skip summaries for chats too short to need one. + summaryMinTranscriptRunes = 200 + chatSummaryWorkTimeout = 120 * time.Second chatSummaryGenerateTimeout = 60 * time.Second - // chatSummaryWriteTimeout bounds the best-effort persistence of a generated - // whole-chat summary. It is separate from turnStatusLabelWriteTimeout so the - // two policies can be tuned independently. - chatSummaryWriteTimeout = 5 * time.Second + chatSummaryWriteTimeout = 5 * time.Second ) -// maybeGenerateChatSummaryAsync launches background whole-chat summary -// generation after a successful turn on a root chat. It is best-effort: it runs -// detached from the request context so the user's turn is never blocked, and -// logs and swallows errors. +// maybeGenerateChatSummaryAsync launches best-effort whole-chat summary +// generation in the background for a root chat. func (p *Server) maybeGenerateChatSummaryAsync( ctx context.Context, chat database.Chat, @@ -4927,13 +4915,8 @@ func (p *Server) maybeGenerateChatSummaryAsync( if chat.ParentChatID.Valid { return } - // Launch background summary generation through goInflight so Close() waits - // for it and the WaitGroup Add is serialized with drainInflight under - // inflightMu, matching the sibling finalize and last-turn-summary helpers. A - // direct p.inflight.Go would Add to the WaitGroup outside inflightMu and - // before the shutdown check, which can race drainInflight's Wait. goInflight - // instead drops the launch once shutdown has begun, so Close() is never - // blocked for up to chatSummaryWorkTimeout by a turn finishing concurrently. + // goInflight serializes the WaitGroup add with shutdown drain and drops the + // launch once closing, so Close() is not blocked by an in-flight summary. if err := p.goInflight(func() { p.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, logger) }); err != nil { @@ -4942,9 +4925,8 @@ func (p *Server) maybeGenerateChatSummaryAsync( } } -// generateAndStoreChatSummary regenerates the whole-chat summary when the -// cadence gate allows, then stores it. It is best-effort and never clears an -// existing summary on failure. +// generateAndStoreChatSummary regenerates and persists the whole-chat summary +// when due. Best-effort; never clears an existing summary on failure. func (p *Server) generateAndStoreChatSummary( ctx context.Context, chat database.Chat, @@ -4956,17 +4938,11 @@ func (p *Server) generateAndStoreChatSummary( //nolint:gocritic // Narrow daemon access for best-effort summary generation. authCtx := dbauthz.AsChatd(ctx) - // Re-read the chat before loading the transcript so the captured - // history_version is no newer than the messages it guards. The chat passed - // in is a snapshot from when the turn finished; reading it fresh also lets - // the cadence gate see the latest Summary and SummaryGeneratedAt, so two - // rapid back-to-back turns do not both pass the gate and call the LLM. - // - // Ordering matters for correctness: if a new turn commits between these two - // reads, the captured history_version stays behind the transcript, so the - // UpdateChatSummary guard rejects the write rather than persisting a summary - // that omits the just-committed turn and advancing the cadence marker past - // it. + // Read the chat (and its history_version) before the transcript: if a turn + // commits between the two reads, the captured history_version stays behind + // the transcript, so UpdateChatSummary rejects the stale write instead of + // persisting a summary that omits the new turn. The fresh read also gives + // the cadence gate the latest Summary/SummaryGeneratedAt. chat, err := p.db.GetChatByID(authCtx, chat.ID) if err != nil { logger.Debug(ctx, "failed to re-read chat for summary", @@ -4994,12 +4970,9 @@ func (p *Server) generateAndStoreChatSummary( return } - // Derive the model build options from the transcript just loaded rather - // than from the turn that launched this goroutine. A detached summary - // goroutine can run after a later turn has committed, so the launching - // turn's options (notably ActiveAPIKeyID, required for AI Gateway routing) - // may be stale or belong to a different turn. Thread the key onto the - // context too, matching deriveFinalTurnRunResult and the generation path. + // Derive model options from the freshly loaded transcript, not the + // launching turn: this goroutine may outlive that turn, and AI Gateway + // routing needs the current transcript's ActiveAPIKeyID. modelOpts := modelBuildOptionsFromMessages(messages) authCtx = withActiveTurnAPIKeyID(authCtx, modelOpts) @@ -5021,8 +4994,6 @@ func (p *Server) generateAndStoreChatSummary( p.updateChatSummary(ctx, chat, chat.HistoryVersion, summary, logger) } -// resolveChatSummaryModel resolves the chat's configured model for summary -// generation. func (p *Server) resolveChatSummaryModel( ctx context.Context, chat database.Chat, @@ -5039,9 +5010,8 @@ func (p *Server) resolveChatSummaryModel( return model, dbConfig, true } -// shouldGenerateChatSummary applies the cadence gate: generate the first summary -// once enough turns exist, then regenerate every summaryRefreshTurnThreshold -// completed turns since the last summary. +// shouldGenerateChatSummary is the cadence gate: first summary after enough +// turns, then every summaryRefreshTurnThreshold turns since the last one. func shouldGenerateChatSummary(chat database.Chat, messages []database.ChatMessage) bool { if !chat.Summary.Valid { return countCompletedTurnsSince(messages, time.Time{}) >= summaryFirstTurnThreshold @@ -5053,11 +5023,9 @@ func shouldGenerateChatSummary(chat database.Chat, messages []database.ChatMessa return countCompletedTurnsSince(messages, marker) >= summaryRefreshTurnThreshold } -// countCompletedTurnsSince counts visible user messages created after the given -// time. Each visible user message starts one turn, so this counts turns -// regardless of how many tool-call steps each turn produced. Hidden model-only -// user messages (injected context, the replayed compaction summary) are not -// turns. A zero time counts all turns. +// countCompletedTurnsSince counts visible user messages (one per turn) created +// after the given time. Model-only user messages (injected context, replayed +// compaction summary) are not turns; a zero time counts all. func countCompletedTurnsSince(messages []database.ChatMessage, after time.Time) int { count := 0 for _, message := range messages { @@ -5076,9 +5044,9 @@ func countCompletedTurnsSince(messages []database.ChatMessage, after time.Time) return count } -// updateChatSummary writes the persisted whole-chat summary for a chat. Callers -// should pass a detached context because this is a best-effort background write. -// It never clears an existing summary: a blank summary is a no-op. +// updateChatSummary persists the whole-chat summary. Best-effort background +// write (pass a detached context); a blank summary is a no-op, never clearing +// an existing one. func (p *Server) updateChatSummary( ctx context.Context, chat database.Chat, diff --git a/coderd/x/chatd/generation_model_override.go b/coderd/x/chatd/generation_model_override.go index a3ff1ac7637..7fc0bfe7db6 100644 --- a/coderd/x/chatd/generation_model_override.go +++ b/coderd/x/chatd/generation_model_override.go @@ -12,12 +12,10 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" ) -// resolveGenerationModelOverride resolves a deployment-wide model override for a -// background generation feature (title or summary). overrideContext labels the -// override (and its error messages); readOverride loads the configured value. -// overrideSet is true when an override was configured; in that case any returned -// error is a hard failure and the caller should skip generation. When -// overrideSet is false, callers fall back to the chat's configured model. +// resolveGenerationModelOverride resolves a deployment-wide model override for +// background generation (title or summary). When overrideSet is true, a +// returned error is a hard failure and the caller should skip generation; +// when false, callers fall back to the chat's configured model. func (p *Server) resolveGenerationModelOverride( ctx context.Context, chat database.Chat, diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 9de9bc08767..ae1f671891d 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -881,22 +881,15 @@ const chatSummaryGenerationPrompt = "You summarize an AI coding chat for a quick "No markdown, lists, headings, code fences, or surrounding quotes." const ( - // summaryTranscriptMaxRunes bounds the rendered conversation passed to the - // summary model so the call stays cheap and within the context window. - // Long chats are bounded by keeping head and tail turns (see - // renderChatSummaryTranscript). + // Bound the transcript so the summary call stays cheap and within context; + // long chats keep head and tail turns (see renderChatSummaryTranscript). summaryTranscriptMaxRunes = 16000 - // summaryTranscriptPerMessageMaxRunes caps any single rendered turn so one - // very long message (such as a replayed compaction summary) cannot dominate - // the transcript budget. + // Cap a single turn so one long message cannot dominate the budget. summaryTranscriptPerMessageMaxRunes = 4000 summaryMaxOutputTokens = 512 - // summaryMaxRunes rejects pathologically long summaries that ignore the - // length instruction. - summaryMaxRunes = 1000 - // summaryMaxSentences rejects pathologically verbose summaries while leaving - // slack over the 1-3 sentence target so a slightly long but useful summary - // is still stored. + // Reject pathologically long or verbose summaries, with slack over the + // 1-3 sentence target. + summaryMaxRunes = 1000 summaryMaxSentences = 6 ) @@ -905,9 +898,7 @@ type generatedChatSummary struct { } // renderChatSummaryTranscript renders chat history as plain text for summary -// generation. It keeps the compaction summary so pre-compaction content is -// covered. Plain text avoids provider tool-call pairing rules during structured -// generation. +// generation. Plain text avoids provider tool-call pairing rules. func renderChatSummaryTranscript(messages []database.ChatMessage) string { lines := make([]string, 0, len(messages)) for _, message := range messages { @@ -921,9 +912,8 @@ func renderChatSummaryTranscript(messages []database.ChatMessage) string { continue } - // Keep visible turns plus the replayed compaction summary, which is a - // model-only compressed message. Other model-only messages (such as - // injected context) are skipped as noise. + // Keep visible turns plus the compaction summary (model-only but + // compressed); skip other model-only messages as noise. visible := message.Visibility == database.ChatMessageVisibilityBoth || message.Visibility == database.ChatMessageVisibilityUser compactionSummary := message.Visibility == database.ChatMessageVisibilityModel && @@ -946,10 +936,9 @@ func renderChatSummaryTranscript(messages []database.ChatMessage) string { return boundTranscriptHeadTail(lines, summaryTranscriptMaxRunes) } -// boundTranscriptHeadTail joins lines, and if the result exceeds maxRunes keeps -// a head and tail slice within budget with an elision marker in between. This -// keeps very long chats bounded while preserving both the start (what the chat -// is about) and the end (what was most recently accomplished). +// boundTranscriptHeadTail joins lines; if over maxRunes it keeps a head and +// tail slice with an elision marker between, preserving the chat's start and +// most recent activity. func boundTranscriptHeadTail(lines []string, maxRunes int) string { if len(lines) == 0 { return "" @@ -994,9 +983,8 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string { } // generateChatSummary generates a 1-3 sentence whole-chat summary from a -// rendered transcript using structured output. It returns the model usage so -// callers can record cost. A blank or invalid result returns an error so -// callers preserve any existing summary rather than clearing it. +// transcript. A blank or invalid result returns an error so callers preserve +// any existing summary rather than clearing it. func generateChatSummary( ctx context.Context, model fantasy.LanguageModel, @@ -1062,11 +1050,9 @@ func validateGeneratedChatSummary(summary string) error { return nil } -// countSentenceTerminators counts sentence-ending punctuation. It only counts a -// terminator that is followed by whitespace or ends the text, so periods inside -// dotted identifiers (pkg.cmd.server, file paths) that the prompt asks the model -// to preserve do not inflate the count. It is an approximation used only as a -// safety net against pathologically verbose output. +// countSentenceTerminators counts sentence-ending punctuation, but only when +// followed by whitespace or end-of-text, so periods inside dotted identifiers +// (pkg.cmd.server, file paths) do not inflate the count. func countSentenceTerminators(text string) int { runes := []rune(text) count := 0 diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index d6b5ae08a03..d12a108bebe 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -56,23 +56,17 @@ func TestRenderChatSummaryTranscript(t *testing.T) { base := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC) messages := []database.ChatMessage{ - // System prompt is skipped as boilerplate. summaryTextMessage(t, 1, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "you are a helpful agent", false, base), - // Replayed compaction summary: model-only but compressed, kept. + // Compaction summary (model-only but compressed) is kept. summaryTextMessage(t, 2, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "earlier work compaction summary", true, base.Add(time.Minute)), - // Injected context: model-only and not compressed, skipped as noise. + // Injected context (model-only, not compressed) is skipped as noise. summaryTextMessage(t, 3, database.ChatMessageRoleUser, database.ChatMessageVisibilityModel, "AGENTS.md injected context", false, base.Add(2*time.Minute)), - // Normal visible turn. summaryTextMessage(t, 4, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, "fix the bug in foo.go", false, base.Add(3*time.Minute)), - // User-only message ("user" visibility) is also included. summaryTextMessage(t, 8, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "and please keep it simple", false, base.Add(3*time.Minute+30*time.Second)), - // Assistant tool-call only message (no text) is skipped. summaryTestMessage(t, 5, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, []codersdk.ChatMessagePart{codersdk.ChatMessageToolCall("call-1", "bash", []byte(`{"cmd":"go test"}`))}, false, base.Add(4*time.Minute)), - // Tool result is skipped. summaryTextMessage(t, 6, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "tests passed", false, base.Add(5*time.Minute)), - // Assistant final text is kept. summaryTextMessage(t, 7, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "fixed the bug and added a test", false, base.Add(6*time.Minute)), } @@ -158,8 +152,7 @@ func TestShouldGenerateChatSummary(t *testing.T) { Summary: sql.NullString{String: "existing", Valid: true}, SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true}, } - // One user turn after the marker, but many assistant messages (tool - // steps). Counting user turns keeps this below the refresh threshold. + // One user turn plus many assistant steps stays below the threshold. msgs := []database.ChatMessage{ userMsg(1, marker.Add(time.Minute)), assistantMsg(2, marker.Add(2*time.Minute)), @@ -184,9 +177,8 @@ func TestShouldGenerateChatSummary(t *testing.T) { CreatedAt: at, } } - // Two real user turns stay below the refresh threshold of 3. The - // model-only user message (such as injected AGENTS.md context) must not - // count as a turn; if it did, these three messages would trip the gate. + // The model-only user message must not count as a turn, else these + // three messages would trip the threshold of 3. msgs := []database.ChatMessage{ userMsg(1, marker.Add(time.Minute)), modelOnlyUserMsg(2, marker.Add(2*time.Minute)), @@ -219,10 +211,8 @@ func TestShouldGenerateChatSummary(t *testing.T) { Summary: sql.NullString{String: "existing", Valid: true}, SummaryGeneratedAt: sql.NullTime{Time: marker, Valid: true}, } - // Two post-marker turns stay below the refresh threshold of 3. The - // pre-marker turn would tip the total to 3, so this stays false only - // because countCompletedTurnsSince excludes turns at or before the - // marker. Dropping that time filter would flip this to true. + // The pre-marker turn would tip the total to the threshold; this stays + // false only because countCompletedTurnsSince excludes pre-marker turns. msgs := []database.ChatMessage{ userMsg(1, marker.Add(-time.Minute)), userMsg(2, marker.Add(time.Minute)), @@ -244,14 +234,12 @@ func TestValidateGeneratedChatSummary(t *testing.T) { func TestCountSentenceTerminators(t *testing.T) { t.Parallel() - // Only terminators followed by whitespace or end-of-text count, so periods - // inside dotted identifiers are not treated as sentence boundaries. + // Periods inside dotted identifiers (pkg.cmd.server) are not boundaries. require.Equal(t, 2, countSentenceTerminators("Fixed pkg.cmd.server in file.go. Added a test.")) require.Equal(t, 3, countSentenceTerminators("One. Two! Three?")) require.Equal(t, 0, countSentenceTerminators("auth.rbac.Policy")) - // A summary dense with dotted identifiers stays under summaryMaxSentences - // and is accepted, where naive per-period counting would reject it. + // Dotted identifiers must not push a valid summary over the sentence cap. require.NoError(t, validateGeneratedChatSummary( "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go. Added coverage in foo_test.go.", )) diff --git a/codersdk/chats.go b/codersdk/chats.go index 81809682045..6a691775ef7 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -123,8 +123,8 @@ type Chat struct { PlanMode ChatPlanMode `json:"plan_mode,omitempty"` LastError *ChatError `json:"last_error,omitempty"` LastTurnSummary *string `json:"last_turn_summary"` - // Summary is the persisted whole-chat summary, generated asynchronously in - // the background. It is nil until the first summary has been produced. + // Summary is the persisted whole-chat summary, generated in the background. + // It is nil until the first summary has been produced. Summary *string `json:"summary"` DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` CreatedAt time.Time `json:"created_at" format:"date-time"` @@ -1768,10 +1768,9 @@ type ChatWatchEventKind string const ( ChatWatchEventKindStatusChange ChatWatchEventKind = "status_change" ChatWatchEventKindSummaryChange ChatWatchEventKind = "summary_change" - // ChatWatchEventKindChatSummaryChange delivers updates to the persisted - // whole-chat summary field. It is distinct from SummaryChange, which is - // bound to last_turn_summary, so the frontend can apply only the summary - // field without disturbing last_turn_summary. + // ChatWatchEventKindChatSummaryChange carries the persisted whole-chat + // summary. It is distinct from SummaryChange (bound to last_turn_summary) so + // the frontend updates one field without disturbing the other. ChatWatchEventKindChatSummaryChange ChatWatchEventKind = "chat_summary_change" ChatWatchEventKindTitleChange ChatWatchEventKind = "title_change" ChatWatchEventKindCreated ChatWatchEventKind = "created" diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index a80ec1bc2f6..f05c25f07eb 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -217,7 +217,7 @@ Status Code **200** | `» root_chat_id` | string(uuid) | false | | | | `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» summary` | string | false | | Summary is the persisted whole-chat summary, generated asynchronously in the background. It is nil until the first summary has been produced. | +| `» summary` | string | false | | Summary is the persisted whole-chat summary, generated in the background. It is nil until the first summary has been produced. | | `» title` | string | false | | | | `» updated_at` | string(date-time) | false | | | | `» warnings` | array | false | | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1c4f6d40ef8..9e986420bef 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2229,7 +2229,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `root_chat_id` | string | false | | | | `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | | `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | -| `summary` | string | false | | Summary is the persisted whole-chat summary, generated asynchronously in the background. It is nil until the first summary has been produced. | +| `summary` | string | false | | Summary is the persisted whole-chat summary, generated in the background. It is nil until the first summary has been produced. | | `title` | string | false | | | | `updated_at` | string | false | | | | `warnings` | array of string | false | | | diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 34b12adaa7a..f6552843c16 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -457,12 +457,9 @@ export const mergeWatchedChatSummary = ( const nextLastModelConfigId = isFreshEnough ? watchedChat.last_model_config_id : cachedChat.last_model_config_id; - // last_turn_summary and the whole-chat summary each have a dedicated event, - // and both preserve the triggering turn's updated_at, so summary_change and - // chat_summary_change carry an equal timestamp. Scope each field strictly to - // its own event: keying off isFreshEnough would let a summary_change clobber - // summary (and a chat_summary_change clobber last_turn_summary) with the - // event's stale snapshot of the other field. + // summary_change and chat_summary_change share the triggering turn's + // updated_at, so isFreshEnough cannot distinguish them. Scope each field to + // its own event, else one event clobbers the other field's value. const nextLastTurnSummary = isSummaryEvent ? watchedChat.last_turn_summary : cachedChat.last_turn_summary; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1c6b538949a..cfb294b61c6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1567,8 +1567,8 @@ export interface Chat { readonly last_error?: ChatError; readonly last_turn_summary: string | null; /** - * Summary is the persisted whole-chat summary, generated asynchronously in - * the background. It is nil until the first summary has been produced. + * Summary is the persisted whole-chat summary, generated in the background. + * It is nil until the first summary has been produced. */ readonly summary: string | null; readonly diff_status?: ChatDiffStatus; From 3088e76bbae8cd0234ca3abb84881150785ee5f5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 30 Jun 2026 09:29:36 +0000 Subject: [PATCH 10/24] chore: update comment --- coderd/database/querier.go | 10 ++++++---- coderd/database/queries.sql.go | 10 ++++++---- coderd/database/queries/chats.sql | 10 ++++++---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b49e98c8d88..2458ab3ebc2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1375,10 +1375,12 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error) - // Stores blank summaries as NULL. summary_generated_at drives the regeneration - // cadence. The staleness guard is history_version (not updated_at, which is - // preserved), mirroring UpdateChatLastTurnSummary: a background write racing a - // newer message change loses, but worker transitions cannot reject a fresh write. + // Trims the summary, storing blank values as NULL, and stamps + // summary_generated_at (used to schedule the next regeneration). + // Guards on history_version, not updated_at (left untouched), so the write + // is rejected only when the message history changed under it; unrelated + // worker state transitions cannot block it. Same pattern as + // UpdateChatLastTurnSummary. UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 00441c7f682..b0adb2cb80c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12367,10 +12367,12 @@ type UpdateChatSummaryParams struct { ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` } -// Stores blank summaries as NULL. summary_generated_at drives the regeneration -// cadence. The staleness guard is history_version (not updated_at, which is -// preserved), mirroring UpdateChatLastTurnSummary: a background write racing a -// newer message change loses, but worker transitions cannot reject a fresh write. +// Trims the summary, storing blank values as NULL, and stamps +// summary_generated_at (used to schedule the next regeneration). +// Guards on history_version, not updated_at (left untouched), so the write +// is rejected only when the message history changed under it; unrelated +// worker state transitions cannot block it. Same pattern as +// UpdateChatLastTurnSummary. func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, arg.ID, arg.ExpectedHistoryVersion) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index a3e6666b956..d80aa93f8f1 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1359,10 +1359,12 @@ WHERE AND history_version = @expected_history_version::bigint; -- name: UpdateChatSummary :execrows --- Stores blank summaries as NULL. summary_generated_at drives the regeneration --- cadence. The staleness guard is history_version (not updated_at, which is --- preserved), mirroring UpdateChatLastTurnSummary: a background write racing a --- newer message change loses, but worker transitions cannot reject a fresh write. +-- Trims the summary, storing blank values as NULL, and stamps +-- summary_generated_at (used to schedule the next regeneration). +-- Guards on history_version, not updated_at (left untouched), so the write +-- is rejected only when the message history changed under it; unrelated +-- worker state transitions cannot block it. Same pattern as +-- UpdateChatLastTurnSummary. UPDATE chats SET summary = NULLIF(REGEXP_REPLACE( From b42dba0c53ed5f60dcbd859ca076f695760a8965 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 30 Jun 2026 13:14:50 +0000 Subject: [PATCH 11/24] chore: cleanup --- coderd/database/querier.go | 4 +-- coderd/database/querier_test.go | 2 +- coderd/database/queries.sql.go | 8 +++--- coderd/database/queries/chats.sql | 8 +++--- coderd/x/chatd/chatd_internal_test.go | 38 +++++++++++++++++++++++++++ coderd/x/chatd/quickgen.go | 21 +++++++++++---- 6 files changed, 63 insertions(+), 18 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2458ab3ebc2..3e720580c92 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1375,8 +1375,8 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error) - // Trims the summary, storing blank values as NULL, and stamps - // summary_generated_at (used to schedule the next regeneration). + // Stores the summary and stamps summary_generated_at (used to schedule the + // next regeneration). // Guards on history_version, not updated_at (left untouched), so the write // is rejected only when the message history changed under it; unrelated // worker state transitions cannot block it. Same pattern as diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index a35c1e771ef..758da269e1e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12544,7 +12544,7 @@ func TestUpdateChatSummary(t *testing.T) { affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, - Summary: sql.NullString{String: " \n\t ", Valid: true}, + Summary: sql.NullString{}, }) require.NoError(t, err) require.EqualValues(t, 1, affected) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b0adb2cb80c..a635bb853b6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12352,9 +12352,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg const updateChatSummary = `-- name: UpdateChatSummary :execrows UPDATE chats SET - summary = NULLIF(REGEXP_REPLACE( - $1::text, '^[[:space:]]+|[[:space:]]+$', '', 'g' - ), ''), + summary = $1::text, summary_generated_at = NOW() WHERE id = $2::uuid @@ -12367,8 +12365,8 @@ type UpdateChatSummaryParams struct { ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` } -// Trims the summary, storing blank values as NULL, and stamps -// summary_generated_at (used to schedule the next regeneration). +// Stores the summary and stamps summary_generated_at (used to schedule the +// next regeneration). // Guards on history_version, not updated_at (left untouched), so the write // is rejected only when the message history changed under it; unrelated // worker state transitions cannot block it. Same pattern as diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index d80aa93f8f1..fe0152c9b53 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1359,17 +1359,15 @@ WHERE AND history_version = @expected_history_version::bigint; -- name: UpdateChatSummary :execrows --- Trims the summary, storing blank values as NULL, and stamps --- summary_generated_at (used to schedule the next regeneration). +-- Stores the summary and stamps summary_generated_at (used to schedule the +-- next regeneration). -- Guards on history_version, not updated_at (left untouched), so the write -- is rejected only when the message history changed under it; unrelated -- worker state transitions cannot block it. Same pattern as -- UpdateChatLastTurnSummary. UPDATE chats SET - summary = NULLIF(REGEXP_REPLACE( - sqlc.narg('summary')::text, '^[[:space:]]+|[[:space:]]+$', '', 'g' - ), ''), + summary = sqlc.narg('summary')::text, summary_generated_at = NOW() WHERE id = @id::uuid diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 3c15609a3c4..1e5ff40ed54 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -82,6 +82,44 @@ func (t *testMCPAgentTool) MCPServerConfigID() uuid.UUID { return t.configID } +func TestUpdateChatSummaryTrimsAndSkipsBlank(t *testing.T) { + t.Parallel() + + t.Run("TrimsBeforePersisting", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db} + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "trimmed summary", Valid: true}, + }).DoAndReturn(func(ctx context.Context, _ database.UpdateChatSummaryParams) (int64, error) { + _, ok := dbauthz.ActorFromContext(ctx) + require.True(t, ok, "summary writes must have an actor") + return 1, nil + }) + + server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, " \n trimmed summary\t ", logger) + }) + + t.Run("SkipsBlankSummary", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db} + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, " \n\t ", logger) + }) +} + func TestComputerUseProviderAndModelFromConfig(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index ae1f671891d..a2c6d818e02 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -973,13 +973,24 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string { tailStart-- } - out := make([]string, 0, headEnd+(len(lines)-tailStart)+1) - out = append(out, lines[:headEnd]...) + var out strings.Builder + writeLine := func(line string) { + if out.Len() > 0 { + out.WriteByte('\n') + } + out.WriteString(line) + } + + for _, line := range lines[:headEnd] { + writeLine(line) + } if tailStart > headEnd { - out = append(out, "[... earlier turns omitted ...]") + writeLine("[... earlier turns omitted ...]") + } + for _, line := range lines[tailStart:] { + writeLine(line) } - out = append(out, lines[tailStart:]...) - return strings.Join(out, "\n") + return out.String() } // generateChatSummary generates a 1-3 sentence whole-chat summary from a From 4dd0385a86a9c1181aed769c4c2d40fa657da66f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 1 Jul 2026 13:03:46 +0000 Subject: [PATCH 12/24] chore: cleanup --- coderd/database/querier.go | 8 ++------ coderd/database/querier_test.go | 7 +------ coderd/database/queries.sql.go | 8 ++------ coderd/database/queries/chats.sql | 8 ++------ coderd/x/chatd/chatd.go | 20 ++++++-------------- 5 files changed, 13 insertions(+), 38 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3e720580c92..558c9686300 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1375,12 +1375,8 @@ type sqlcQuerier interface { UpdateChatRetryState(ctx context.Context, arg UpdateChatRetryStateParams) (Chat, error) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error) - // Stores the summary and stamps summary_generated_at (used to schedule the - // next regeneration). - // Guards on history_version, not updated_at (left untouched), so the write - // is rejected only when the message history changed under it; unrelated - // worker state transitions cannot block it. Same pattern as - // UpdateChatLastTurnSummary. + // The history_version fence lets background summary writes ignore worker-only + // updates while losing to newer message history. UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitleByIDParams) (Chat, error) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 758da269e1e..9dd16d2d795 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12524,8 +12524,6 @@ func TestUpdateChatSummary(t *testing.T) { require.False(t, chat.Summary.Valid) require.False(t, chat.SummaryGeneratedAt.Valid) - // Writing a summary stores it, records summary_generated_at, and does not - // bump updated_at. affected, err := db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, @@ -12540,7 +12538,6 @@ func TestUpdateChatSummary(t *testing.T) { require.True(t, fetched.SummaryGeneratedAt.Valid) require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) - // Blank summaries are stored as NULL. affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, @@ -12554,9 +12551,7 @@ func TestUpdateChatSummary(t *testing.T) { require.False(t, fetched.Summary.Valid) require.Equal(t, chat.UpdatedAt, fetched.UpdatedAt) - // Establish a known-good summary, then advance history_version with a new - // turn so a write carrying the stale history_version is rejected. This is - // how background summary writes racing a newer turn lose. + // Background summaries generated from stale history must lose to newer turns. affected, err = db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a635bb853b6..7a6ae2d5362 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12365,12 +12365,8 @@ type UpdateChatSummaryParams struct { ExpectedHistoryVersion int64 `db:"expected_history_version" json:"expected_history_version"` } -// Stores the summary and stamps summary_generated_at (used to schedule the -// next regeneration). -// Guards on history_version, not updated_at (left untouched), so the write -// is rejected only when the message history changed under it; unrelated -// worker state transitions cannot block it. Same pattern as -// UpdateChatLastTurnSummary. +// The history_version fence lets background summary writes ignore worker-only +// updates while losing to newer message history. func (q *sqlQuerier) UpdateChatSummary(ctx context.Context, arg UpdateChatSummaryParams) (int64, error) { result, err := q.db.ExecContext(ctx, updateChatSummary, arg.Summary, arg.ID, arg.ExpectedHistoryVersion) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index fe0152c9b53..52e5edef131 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1359,12 +1359,8 @@ WHERE AND history_version = @expected_history_version::bigint; -- name: UpdateChatSummary :execrows --- Stores the summary and stamps summary_generated_at (used to schedule the --- next regeneration). --- Guards on history_version, not updated_at (left untouched), so the write --- is rejected only when the message history changed under it; unrelated --- worker state transitions cannot block it. Same pattern as --- UpdateChatLastTurnSummary. +-- The history_version fence lets background summary writes ignore worker-only +-- updates while losing to newer message history. UPDATE chats SET summary = sqlc.narg('summary')::text, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 7f29ba9092c..360408895c9 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4894,15 +4894,12 @@ func (p *Server) updateLastTurnSummary( } const ( - // Cadence gate bounding LLM spend: turns before the first summary, then - // turns between refreshes. summaryFirstTurnThreshold = 1 summaryRefreshTurnThreshold = 3 - // Skip summaries for chats too short to need one. - summaryMinTranscriptRunes = 200 - chatSummaryWorkTimeout = 120 * time.Second - chatSummaryGenerateTimeout = 60 * time.Second - chatSummaryWriteTimeout = 5 * time.Second + summaryMinTranscriptRunes = 200 + chatSummaryWorkTimeout = 120 * time.Second + chatSummaryGenerateTimeout = 60 * time.Second + chatSummaryWriteTimeout = 5 * time.Second ) // maybeGenerateChatSummaryAsync launches best-effort whole-chat summary @@ -4938,11 +4935,8 @@ func (p *Server) generateAndStoreChatSummary( //nolint:gocritic // Narrow daemon access for best-effort summary generation. authCtx := dbauthz.AsChatd(ctx) - // Read the chat (and its history_version) before the transcript: if a turn - // commits between the two reads, the captured history_version stays behind - // the transcript, so UpdateChatSummary rejects the stale write instead of - // persisting a summary that omits the new turn. The fresh read also gives - // the cadence gate the latest Summary/SummaryGeneratedAt. + // If a turn commits after this read, the stale history_version makes the + // eventual summary write lose instead of omitting that newer turn. chat, err := p.db.GetChatByID(authCtx, chat.ID) if err != nil { logger.Debug(ctx, "failed to re-read chat for summary", @@ -5010,8 +5004,6 @@ func (p *Server) resolveChatSummaryModel( return model, dbConfig, true } -// shouldGenerateChatSummary is the cadence gate: first summary after enough -// turns, then every summaryRefreshTurnThreshold turns since the last one. func shouldGenerateChatSummary(chat database.Chat, messages []database.ChatMessage) bool { if !chat.Summary.Valid { return countCompletedTurnsSince(messages, time.Time{}) >= summaryFirstTurnThreshold From 36429a4571db417241ab41b0f649c7e79ca5359e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 8 Jul 2026 05:58:19 +0000 Subject: [PATCH 13/24] fix: summary goroutine no longer stalls server shutdown --- coderd/x/chatd/chatd.go | 7 +++--- coderd/x/chatd/chatd_internal_test.go | 36 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 4b0d8854880..b25a94a2224 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4788,11 +4788,12 @@ func (p *Server) maybeGenerateChatSummaryAsync( if chat.ParentChatID.Valid { return } - // goInflight serializes the WaitGroup add with shutdown drain and drops the - // launch once closing, so Close() is not blocked by an in-flight summary. + summaryCtx, stopSummaryCtx := p.inflightContext(ctx) if err := p.goInflight(func() { - p.generateAndStoreChatSummary(context.WithoutCancel(ctx), chat, logger) + defer stopSummaryCtx() + p.generateAndStoreChatSummary(summaryCtx, chat, logger) }); err != nil { + stopSummaryCtx() logger.Debug(context.WithoutCancel(ctx), "skipped chat summary generation", slog.F("chat_id", chat.ID), slog.Error(err)) } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 22073922f60..77970074029 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -125,6 +125,42 @@ func TestUpdateChatSummaryTrimsAndSkipsBlank(t *testing.T) { }) } +func TestMaybeGenerateChatSummaryAsync_CloseCancelsInflight(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + serverCtx, serverCancel := context.WithCancel(context.Background()) + t.Cleanup(serverCancel) + server := &Server{ctx: serverCtx, cancel: serverCancel, db: db} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} + + entered := make(chan struct{}) + db.EXPECT().GetChatByID(gomock.Any(), chat.ID).DoAndReturn( + func(readCtx context.Context, _ uuid.UUID) (database.Chat, error) { + close(entered) + // Hang like an unreachable callee until the context is + // canceled; only server shutdown can release this before + // chatSummaryWorkTimeout. + <-readCtx.Done() + return database.Chat{}, readCtx.Err() + }, + ) + + server.maybeGenerateChatSummaryAsync(ctx, chat, logger) + testutil.TryReceive(ctx, t, entered) + + closed := make(chan struct{}) + go func() { + defer close(closed) + _ = server.Close() + }() + testutil.TryReceive(ctx, t, closed) +} + func TestComputerUseProviderAndModelFromConfig(t *testing.T) { t.Parallel() From 15c3e683061ab6660185dace092076422b049a7b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 8 Jul 2026 08:57:48 +0000 Subject: [PATCH 14/24] fix(coderd/database/migrations): renumber chat_summary migration to 000541 Main added 000540_workspace_build_orchestrations, which collided with this branch's 000540_chat_summary and made golang-migrate fail with "duplicate migration file" in every Postgres-backed test. Renumber to the next free slot. The migration content is unchanged and the chats_expanded recreation still matches the current schema; workspace_build_orchestrations does not touch the chats table. --- ...{000540_chat_summary.down.sql => 000541_chat_summary.down.sql} | 0 .../{000540_chat_summary.up.sql => 000541_chat_summary.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000540_chat_summary.down.sql => 000541_chat_summary.down.sql} (100%) rename coderd/database/migrations/{000540_chat_summary.up.sql => 000541_chat_summary.up.sql} (100%) diff --git a/coderd/database/migrations/000540_chat_summary.down.sql b/coderd/database/migrations/000541_chat_summary.down.sql similarity index 100% rename from coderd/database/migrations/000540_chat_summary.down.sql rename to coderd/database/migrations/000541_chat_summary.down.sql diff --git a/coderd/database/migrations/000540_chat_summary.up.sql b/coderd/database/migrations/000541_chat_summary.up.sql similarity index 100% rename from coderd/database/migrations/000540_chat_summary.up.sql rename to coderd/database/migrations/000541_chat_summary.up.sql From dc855a85d2fdde8fe0114021c4e0ccb881449106 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 8 Jul 2026 14:59:01 +0000 Subject: [PATCH 15/24] chore: improve names --- coderd/x/chatd/chatd.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b25a94a2224..fd6fb9d8a9e 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4770,8 +4770,10 @@ func (p *Server) updateLastTurnSummary( } const ( - summaryFirstTurnThreshold = 1 - summaryRefreshTurnThreshold = 3 + // Completed user turns before the first summary is generated. + summaryInitialTurnThreshold = 1 + // New completed user turns before the summary is regenerated (since the last summary). + summaryStaleTurnThreshold = 3 summaryMinTranscriptRunes = 200 chatSummaryWorkTimeout = 120 * time.Second chatSummaryGenerateTimeout = 60 * time.Second @@ -4883,13 +4885,13 @@ func (p *Server) resolveChatSummaryModel( func shouldGenerateChatSummary(chat database.Chat, messages []database.ChatMessage) bool { if !chat.Summary.Valid { - return countCompletedTurnsSince(messages, time.Time{}) >= summaryFirstTurnThreshold + return countCompletedTurnsSince(messages, time.Time{}) >= summaryInitialTurnThreshold } var marker time.Time if chat.SummaryGeneratedAt.Valid { marker = chat.SummaryGeneratedAt.Time } - return countCompletedTurnsSince(messages, marker) >= summaryRefreshTurnThreshold + return countCompletedTurnsSince(messages, marker) >= summaryStaleTurnThreshold } // countCompletedTurnsSince counts visible user messages (one per turn) created From 378054074d92c4d01e52aea873f230160fe4f0ea Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 8 Jul 2026 15:15:05 +0000 Subject: [PATCH 16/24] fix: format --- coderd/x/chatd/chatd.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index fd6fb9d8a9e..c84521472dd 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4771,13 +4771,13 @@ func (p *Server) updateLastTurnSummary( const ( // Completed user turns before the first summary is generated. - summaryInitialTurnThreshold = 1 + summaryInitialTurnThreshold = 1 // New completed user turns before the summary is regenerated (since the last summary). - summaryStaleTurnThreshold = 3 - summaryMinTranscriptRunes = 200 - chatSummaryWorkTimeout = 120 * time.Second - chatSummaryGenerateTimeout = 60 * time.Second - chatSummaryWriteTimeout = 5 * time.Second + summaryStaleTurnThreshold = 3 + summaryMinTranscriptRunes = 200 + chatSummaryWorkTimeout = 120 * time.Second + chatSummaryGenerateTimeout = 60 * time.Second + chatSummaryWriteTimeout = 5 * time.Second ) // maybeGenerateChatSummaryAsync launches best-effort whole-chat summary From 7656d56ca7b9f2013d90fa8dbbacdbc2196f0bf1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 14 Jul 2026 09:29:16 +0000 Subject: [PATCH 17/24] chore: move ctx closer to usage --- coderd/database/querier_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index dc686e1c24a..1608c31d826 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -13515,7 +13515,6 @@ func TestUpdateChatSummary(t *testing.T) { 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}) @@ -13528,6 +13527,7 @@ func TestUpdateChatSummary(t *testing.T) { CentralApiKeyEnabled: true, }) + ctx := testutil.Context(t, testutil.WaitMedium) modelCfg, err := insertChatModelConfigForTest(ctx, t, db, "openai", database.InsertChatModelConfigParams{ Model: "test-model", DisplayName: "Test Model", From 46713c69bf64b20d19c2df30e7576f4caf34738d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 14 Jul 2026 09:29:53 +0000 Subject: [PATCH 18/24] chore: update comment --- site/src/api/queries/chats.test.ts | 7 ++++--- site/src/api/queries/chats.ts | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index de33216f05b..bafdb5770ca 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2325,9 +2325,10 @@ describe("mergeWatchedChatSummary", () => { last_turn_summary: "Old turn", updated_at: "2025-01-01T00:00:00.000Z", }); - // summary_change and chat_summary_change share the triggering turn's - // updated_at, so the timestamps are equal. The summary_change snapshot - // still carries a stale whole-chat summary from when the turn finished. + // Neither summary write bumps chats.updated_at, so both events replay + // the triggering turn's timestamp and arrive with equal updated_at. The + // summary_change snapshot still carries a stale whole-chat summary from + // when the turn finished. const watchedChat = makeChat("chat-1", { summary: null, last_turn_summary: "New turn", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 6132ed89544..a8cd1ac9642 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -457,9 +457,11 @@ export const mergeWatchedChatSummary = ( const nextLastModelConfigId = isFreshEnough ? watchedChat.last_model_config_id : cachedChat.last_model_config_id; - // summary_change and chat_summary_change share the triggering turn's - // updated_at, so isFreshEnough cannot distinguish them. Scope each field to - // its own event, else one event clobbers the other field's value. + // The summary writes (UpdateChatLastTurnSummary, UpdateChatSummary) never + // bump chats.updated_at, and both events publish pre-write chat snapshots, + // so updated_at cannot order summary_change against chat_summary_change and + // isFreshEnough cannot guard these fields. Scope each field to its own + // event, else one event's stale snapshot clobbers the other field's value. const nextLastTurnSummary = isSummaryEvent ? watchedChat.last_turn_summary : cachedChat.last_turn_summary; From 310be96016f0898943c5b4e9afb35049d3b9dd69 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 14 Jul 2026 09:31:11 +0000 Subject: [PATCH 19/24] chore: improve ctx usage --- coderd/x/chatd/chatd.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 73132808fa4..a62c3a26476 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4617,13 +4617,13 @@ func (p *Server) maybeGenerateChatSummaryAsync( if chat.ParentChatID.Valid { return } - summaryCtx, stopSummaryCtx := p.inflightContext(ctx) + ctx, cancel := p.inflightContext(ctx) if err := p.goInflight(func() { - defer stopSummaryCtx() - p.generateAndStoreChatSummary(summaryCtx, chat, logger) + defer cancel() + p.generateAndStoreChatSummary(ctx, logger, chat) }); err != nil { - stopSummaryCtx() - logger.Debug(context.WithoutCancel(ctx), "skipped chat summary generation", + cancel() + logger.Debug(ctx, "skipped chat summary generation", slog.F("chat_id", chat.ID), slog.Error(err)) } } @@ -4632,8 +4632,8 @@ func (p *Server) maybeGenerateChatSummaryAsync( // when due. Best-effort; never clears an existing summary on failure. func (p *Server) generateAndStoreChatSummary( ctx context.Context, - chat database.Chat, logger slog.Logger, + chat database.Chat, ) { ctx, cancel := context.WithTimeout(ctx, chatSummaryWorkTimeout) defer cancel() From 546e77682cd510cf77197728699557526878e2be Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 14 Jul 2026 09:31:24 +0000 Subject: [PATCH 20/24] chore: add SkipsEventOnStaleWrite test --- coderd/x/chatd/chatd_internal_test.go | 34 ++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index a00e6f3f2f5..af09ded87e3 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -87,15 +87,16 @@ func (t *testMCPAgentTool) MCPServerConfigID() uuid.UUID { return t.configID } -func TestUpdateChatSummaryTrimsAndSkipsBlank(t *testing.T) { +func TestUpdateChatSummary(t *testing.T) { t.Parallel() - t.Run("TrimsBeforePersisting", func(t *testing.T) { + t.Run("TrimsAndPublishes", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - server := &Server{db: db} + ps := newRecordingPubsub(dbpubsub.NewInMemory()) + server := &Server{db: db, pubsub: ps} chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) @@ -110,6 +111,12 @@ func TestUpdateChatSummaryTrimsAndSkipsBlank(t *testing.T) { }) server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, " \n trimmed summary\t ", logger) + + events := ps.watchEvents(t) + require.Len(t, events, 1) + require.Equal(t, codersdk.ChatWatchEventKindChatSummaryChange, events[0].Kind) + require.NotNil(t, events[0].Chat.Summary) + require.Equal(t, "trimmed summary", *events[0].Chat.Summary) }) t.Run("SkipsBlankSummary", func(t *testing.T) { @@ -123,6 +130,27 @@ func TestUpdateChatSummaryTrimsAndSkipsBlank(t *testing.T) { server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, " \n\t ", logger) }) + + t.Run("SkipsEventOnStaleWrite", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + ps := newRecordingPubsub(dbpubsub.NewInMemory()) + server := &Server{db: db, pubsub: ps} + chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "stale summary", Valid: true}, + }).Return(int64(0), nil) + + server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, "stale summary", logger) + + require.Empty(t, ps.watchEvents(t)) + }) } func TestMaybeGenerateChatSummaryAsync_CloseCancelsInflight(t *testing.T) { From 4b99d0223da2bce8a6ff5b934ff88932f6b748b1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 14 Jul 2026 11:35:56 +0000 Subject: [PATCH 21/24] chore: update ctx usage --- coderd/x/chatd/chatd.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index a62c3a26476..7746e1020fb 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4639,18 +4639,18 @@ func (p *Server) generateAndStoreChatSummary( defer cancel() //nolint:gocritic // Narrow daemon access for best-effort summary generation. - authCtx := dbauthz.AsChatd(ctx) + ctx = dbauthz.AsChatd(ctx) // If a turn commits after this read, the stale history_version makes the // eventual summary write lose instead of omitting that newer turn. - chat, err := p.db.GetChatByID(authCtx, chat.ID) + chat, err := p.db.GetChatByID(ctx, chat.ID) if err != nil { logger.Debug(ctx, "failed to re-read chat for summary", slog.F("chat_id", chat.ID), slog.Error(err)) return } - messages, err := p.db.GetChatMessagesForPromptByChatID(authCtx, chat.ID) + messages, err := p.db.GetChatMessagesForPromptByChatID(ctx, chat.ID) if err != nil { logger.Debug(ctx, "failed to load messages for chat summary", slog.F("chat_id", chat.ID), slog.Error(err)) @@ -4674,9 +4674,9 @@ func (p *Server) generateAndStoreChatSummary( // launching turn: this goroutine may outlive that turn, and AI Gateway // routing needs the current transcript's ActiveAPIKeyID. modelOpts := modelBuildOptionsFromMessages(messages) - authCtx = withActiveTurnAPIKeyID(authCtx, modelOpts) + ctx = withActiveTurnAPIKeyID(ctx, modelOpts) - model, _, ok := p.resolveChatSummaryModel(authCtx, chat, modelOpts, logger) + model, _, ok := p.resolveChatSummaryModel(ctx, chat, modelOpts, logger) if !ok { return } From 627f975015fc4a085d3e967d6c71be0ec1ee6344 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 20 Jul 2026 08:33:44 +0000 Subject: [PATCH 22/24] chore: update esclation logic --- coderd/x/chatd/chatd.go | 10 ++++------ coderd/x/chatd/chatd_internal_test.go | 14 +++++++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index d7fc31ea1cc..21b3c30a2f0 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4802,23 +4802,21 @@ func (p *Server) updateChatSummary( } sqlSummary := sql.NullString{String: summary, Valid: true} - //nolint:gocritic // Narrow daemon access for best-effort summary cache writes. - updateCtx := dbauthz.AsChatd(ctx) - updateCtx, cancel := context.WithTimeout(updateCtx, chatSummaryWriteTimeout) + ctx, cancel := context.WithTimeout(ctx, chatSummaryWriteTimeout) defer cancel() - affected, err := p.db.UpdateChatSummary(updateCtx, database.UpdateChatSummaryParams{ + affected, err := p.db.UpdateChatSummary(ctx, database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: expectedHistoryVersion, Summary: sqlSummary, }) if err != nil { - logger.Warn(updateCtx, "failed to update chat summary", + logger.Warn(ctx, "failed to update chat summary", slog.F("chat_id", chat.ID), slog.Error(err)) return } if affected == 0 { - logger.Info(updateCtx, "skipped stale chat summary update", + logger.Info(ctx, "skipped stale chat summary update", slog.F("chat_id", chat.ID), slog.F("summary_length", len(summary)), slog.F("expected_history_version", expectedHistoryVersion), diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index cd87020442a..73b0771fb14 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -99,18 +99,26 @@ func TestUpdateChatSummary(t *testing.T) { server := &Server{db: db, pubsub: ps} chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + caller := rbac.Subject{ + ID: chat.OwnerID.String(), + Type: rbac.SubjectTypeUser, + Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, + } + //nolint:gocritic // Verify updateChatSummary preserves its caller's actor. + ctx := dbauthz.As(context.Background(), caller) db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ ID: chat.ID, ExpectedHistoryVersion: chat.HistoryVersion, Summary: sql.NullString{String: "trimmed summary", Valid: true}, }).DoAndReturn(func(ctx context.Context, _ database.UpdateChatSummaryParams) (int64, error) { - _, ok := dbauthz.ActorFromContext(ctx) - require.True(t, ok, "summary writes must have an actor") + actor, ok := dbauthz.ActorFromContext(ctx) + require.True(t, ok, "summary writes must preserve the caller's actor") + require.Equal(t, caller, actor) return 1, nil }) - server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, " \n trimmed summary\t ", logger) + server.updateChatSummary(ctx, chat, chat.HistoryVersion, " \n trimmed summary\t ", logger) events := ps.watchEvents(t) require.Len(t, events, 1) From a99d1b565c1c7cb86221a70bc20093b8315a2754 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 21 Jul 2026 07:15:26 +0000 Subject: [PATCH 23/24] fix: update migration number --- ...{000548_chat_summary.down.sql => 000549_chat_summary.down.sql} | 0 .../{000548_chat_summary.up.sql => 000549_chat_summary.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000548_chat_summary.down.sql => 000549_chat_summary.down.sql} (100%) rename coderd/database/migrations/{000548_chat_summary.up.sql => 000549_chat_summary.up.sql} (100%) diff --git a/coderd/database/migrations/000548_chat_summary.down.sql b/coderd/database/migrations/000549_chat_summary.down.sql similarity index 100% rename from coderd/database/migrations/000548_chat_summary.down.sql rename to coderd/database/migrations/000549_chat_summary.down.sql diff --git a/coderd/database/migrations/000548_chat_summary.up.sql b/coderd/database/migrations/000549_chat_summary.up.sql similarity index 100% rename from coderd/database/migrations/000548_chat_summary.up.sql rename to coderd/database/migrations/000549_chat_summary.up.sql From fbb40d6539f09235c35c4ce68973f7fbf6241085 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 23 Jul 2026 13:12:38 +0000 Subject: [PATCH 24/24] chore: cleanup --- coderd/x/chatd/chatd.go | 12 ++++++------ coderd/x/chatd/chatd_internal_test.go | 13 ++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 948af90e618..6ca6fa56db2 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4387,7 +4387,7 @@ func (p *Server) maybeFinalizeTurnStatusLabelAndPush( switch status { case database.ChatStatusWaiting: p.finalizeSuccessfulTurnStatusLabelAndPush(ctx, chat, status, runResult, logger) - p.maybeGenerateChatSummaryAsync(ctx, chat, logger) + p.maybeGenerateChatSummaryAsync(ctx, logger, chat) case database.ChatStatusError: p.clearLastTurnSummaryAsync(ctx, chat, logger) @@ -4630,8 +4630,8 @@ const ( // generation in the background for a root chat. func (p *Server) maybeGenerateChatSummaryAsync( ctx context.Context, - chat database.Chat, logger slog.Logger, + chat database.Chat, ) { if chat.ParentChatID.Valid { return @@ -4700,7 +4700,7 @@ func (p *Server) generateAndStoreChatSummary( } modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID} - model, _, ok := p.resolveChatSummaryModel(ctx, chat, modelOpts, logger) + model, _, ok := p.resolveChatSummaryModel(ctx, logger, chat, modelOpts) if !ok { return } @@ -4715,14 +4715,14 @@ func (p *Server) generateAndStoreChatSummary( return } - p.updateChatSummary(ctx, chat, chat.HistoryVersion, summary, logger) + p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, summary) } func (p *Server) resolveChatSummaryModel( ctx context.Context, + logger slog.Logger, chat database.Chat, modelOpts modelBuildOptions, - logger slog.Logger, ) (fantasy.LanguageModel, database.ChatModelConfig, bool) { //nolint:dogsled // resolveChatModel returns rich routing metadata; summary generation only needs the model and its config. model, dbConfig, _, _, _, _, err := p.resolveChatModel(ctx, chat, modelOpts) @@ -4771,10 +4771,10 @@ func countCompletedTurnsSince(messages []database.ChatMessage, after time.Time) // an existing one. func (p *Server) updateChatSummary( ctx context.Context, + logger slog.Logger, chat database.Chat, expectedHistoryVersion int64, summary string, - logger slog.Logger, ) { summary = strings.TrimSpace(summary) if summary == "" { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index f9232b362a7..f5c7b5c6334 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -118,7 +118,7 @@ func TestUpdateChatSummary(t *testing.T) { return 1, nil }) - server.updateChatSummary(ctx, chat, chat.HistoryVersion, " \n trimmed summary\t ", logger) + server.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, " \n trimmed summary\t ") events := ps.watchEvents(t) require.Len(t, events, 1) @@ -136,7 +136,7 @@ func TestUpdateChatSummary(t *testing.T) { chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New(), HistoryVersion: 7} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, " \n\t ", logger) + server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, " \n\t ") }) t.Run("SkipsEventOnStaleWrite", func(t *testing.T) { @@ -155,7 +155,7 @@ func TestUpdateChatSummary(t *testing.T) { Summary: sql.NullString{String: "stale summary", Valid: true}, }).Return(int64(0), nil) - server.updateChatSummary(context.Background(), chat, chat.HistoryVersion, "stale summary", logger) + server.updateChatSummary(context.Background(), logger, chat, chat.HistoryVersion, "stale summary") require.Empty(t, ps.watchEvents(t)) }) @@ -164,8 +164,6 @@ func TestUpdateChatSummary(t *testing.T) { func TestMaybeGenerateChatSummaryAsync_CloseCancelsInflight(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) serverCtx, serverCancel := context.WithCancel(context.Background()) @@ -173,7 +171,6 @@ func TestMaybeGenerateChatSummaryAsync_CloseCancelsInflight(t *testing.T) { server := &Server{ctx: serverCtx, cancel: serverCancel, db: db} logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) chat := database.Chat{ID: uuid.New(), OwnerID: uuid.New()} - entered := make(chan struct{}) db.EXPECT().GetChatByID(gomock.Any(), chat.ID).DoAndReturn( func(readCtx context.Context, _ uuid.UUID) (database.Chat, error) { @@ -186,7 +183,9 @@ func TestMaybeGenerateChatSummaryAsync_CloseCancelsInflight(t *testing.T) { }, ) - server.maybeGenerateChatSummaryAsync(ctx, chat, logger) + ctx := testutil.Context(t, testutil.WaitShort) + server.maybeGenerateChatSummaryAsync(ctx, logger, chat) + testutil.TryReceive(ctx, t, entered) closed := make(chan struct{})