From b7f470f32d7bf1996d6404bbbaa48f07035fa770 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 24 Jun 2026 10:57:19 +0000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 2cf46f32663755786cfa077effab4c65debd815e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 12:01:01 +0000 Subject: [PATCH 7/9] fix(coderd): exclude accounting rows from chat history_version Stack on the simplified chat-summary branch that defers cost accounting. This PR adds cost_source, InsertChatAccountingMessage, and migration 000535 recreating history triggers so only ordinary turn rows advance history_version. Summary and title usage writers record spend via the atomic accounting insert so background summary persistence is not invalidated by its own usage row. Co-authored-by: Cursor --- coderd/database/check_constraint.go | 1 + coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/dump.sql | 9 +- ...35_chat_history_ignore_accounting.down.sql | 43 +++++ ...0535_chat_history_ignore_accounting.up.sql | 64 +++++++ coderd/database/models.go | 1 + coderd/database/querier.go | 14 ++ coderd/database/querier_test.go | 91 +++++++++ coderd/database/queries.sql.go | 163 +++++++++++++++- coderd/database/queries/chats.sql | 59 ++++++ coderd/x/chatd/ARCHITECTURE.md | 48 ++--- coderd/x/chatd/chatd.go | 177 ++++++++++++------ coderd/x/chatd/chatd_internal_test.go | 91 +++++++-- coderd/x/chatd/chatstate/trigger_test.go | 63 +++++++ 16 files changed, 746 insertions(+), 105 deletions(-) create mode 100644 coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql create mode 100644 coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql 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/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bbbe23c254a..e9af712f374 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5855,6 +5855,10 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) return insert(q.log, q.auth, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), q.db.InsertChat)(ctx, arg) } +func (q *querier) InsertChatAccountingMessage(ctx context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { + panic("not implemented") +} + func (q *querier) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 11ae8e0bbb3..d3c00bd4971 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4026,6 +4026,14 @@ func (m queryMetricsStore) InsertChat(ctx context.Context, arg database.InsertCh return r0, r1 } +func (m queryMetricsStore) InsertChatAccountingMessage(ctx context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { + start := time.Now() + r0, r1 := m.s.InsertChatAccountingMessage(ctx, arg) + m.queryLatencies.WithLabelValues("InsertChatAccountingMessage").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertChatAccountingMessage").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { start := time.Now() r0, r1 := m.s.InsertChatDebugRun(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f846a46bf45..95b42c70e49 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7539,6 +7539,21 @@ func (mr *MockStoreMockRecorder) InsertChat(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChat", reflect.TypeOf((*MockStore)(nil).InsertChat), ctx, arg) } +// InsertChatAccountingMessage mocks base method. +func (m *MockStore) InsertChatAccountingMessage(ctx context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatAccountingMessage", ctx, arg) + ret0, _ := ret[0].(database.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertChatAccountingMessage indicates an expected call of InsertChatAccountingMessage. +func (mr *MockStoreMockRecorder) InsertChatAccountingMessage(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatAccountingMessage", reflect.TypeOf((*MockStore)(nil).InsertChatAccountingMessage), ctx, arg) +} + // InsertChatDebugRun mocks base method. func (m *MockStore) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index a208ef4234a..ac7cc3d2aa6 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1402,7 +1402,9 @@ BEGIN SET history_version = c.snapshot_version, generation_attempt = 0 FROM ( - SELECT DISTINCT chat_id FROM chat_message_history_new_rows + SELECT DISTINCT chat_id + FROM chat_message_history_new_rows + WHERE cost_source IS NULL ) AS affected WHERE c.id = affected.chat_id AND ( @@ -1425,6 +1427,7 @@ BEGIN FROM chat_message_history_new_rows n JOIN chat_message_history_old_rows o ON o.id = n.id WHERE o IS DISTINCT FROM n + AND n.cost_source IS NULL ) AS affected WHERE c.id = affected.chat_id AND ( @@ -1895,7 +1898,9 @@ 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, + 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/000535_chat_history_ignore_accounting.down.sql b/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql new file mode 100644 index 00000000000..3435be48e22 --- /dev/null +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql @@ -0,0 +1,43 @@ +-- Restore the original history triggers that advance history_version for every +-- chat_messages change, including hidden accounting rows. +CREATE OR REPLACE FUNCTION update_chat_history_after_message_insert() +RETURNS trigger AS $$ +BEGIN + UPDATE chats c + SET history_version = c.snapshot_version, + generation_attempt = 0 + FROM ( + SELECT DISTINCT chat_id FROM chat_message_history_new_rows + ) AS affected + WHERE c.id = affected.chat_id + AND ( + c.history_version IS DISTINCT FROM c.snapshot_version + OR c.generation_attempt <> 0 + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION update_chat_history_after_message_update() +RETURNS trigger AS $$ +BEGIN + UPDATE chats c + SET history_version = c.snapshot_version, + generation_attempt = 0 + FROM ( + SELECT DISTINCT n.chat_id + FROM chat_message_history_new_rows n + JOIN chat_message_history_old_rows o ON o.id = n.id + WHERE o IS DISTINCT FROM n + ) AS affected + WHERE c.id = affected.chat_id + AND ( + c.history_version IS DISTINCT FROM c.snapshot_version + OR c.generation_attempt <> 0 + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +ALTER TABLE chat_messages + DROP COLUMN cost_source; diff --git a/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql new file mode 100644 index 00000000000..e9f17b43e2a --- /dev/null +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql @@ -0,0 +1,64 @@ +-- 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')); + +-- Background summary and manual title generation write hidden, soft-deleted +-- chat_messages accounting rows tagged with cost_source to attribute their +-- spend. Those rows are not durable conversation history, so they must not +-- advance chats.history_version. Otherwise a summary write guarded on +-- history_version is invalidated by the very accounting row recorded for that +-- same summary, and the write (and the last_turn_summary write racing behind +-- it) is rejected as stale even when no new turn occurred. +-- +-- Recreate the AFTER STATEMENT history triggers so only rows with +-- cost_source IS NULL (ordinary turn history) advance history_version. +-- snapshot_version still advances for every change, so history_version +-- correctly trails behind it after an accounting-only write and catches up on +-- the next real message. +CREATE OR REPLACE FUNCTION update_chat_history_after_message_insert() +RETURNS trigger AS $$ +BEGIN + UPDATE chats c + SET history_version = c.snapshot_version, + generation_attempt = 0 + FROM ( + SELECT DISTINCT chat_id + FROM chat_message_history_new_rows + WHERE cost_source IS NULL + ) AS affected + WHERE c.id = affected.chat_id + AND ( + c.history_version IS DISTINCT FROM c.snapshot_version + OR c.generation_attempt <> 0 + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION update_chat_history_after_message_update() +RETURNS trigger AS $$ +BEGIN + UPDATE chats c + SET history_version = c.snapshot_version, + generation_attempt = 0 + FROM ( + SELECT DISTINCT n.chat_id + FROM chat_message_history_new_rows n + JOIN chat_message_history_old_rows o ON o.id = n.id + WHERE o IS DISTINCT FROM n + AND n.cost_source IS NULL + ) AS affected + WHERE c.id = affected.chat_id + AND ( + c.history_version IS DISTINCT FROM c.snapshot_version + OR c.generation_attempt <> 0 + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7f00e0f2ce6..c7114b469d5 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4951,6 +4951,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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 184228605aa..8ec915d293c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1023,6 +1023,20 @@ type sqlcQuerier interface { InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) + // Inserts a single hidden accounting row whose cost_source is set on the + // initial INSERT. Background summary and manual title generation use this to + // attribute their spend. Tagging cost_source at insert time (rather than via a + // follow-up UPDATE) is required so the AFTER STATEMENT history triggers see the + // row as an accounting row and do not advance chats.history_version: a turn + // summary write is guarded on history_version, and a row that advanced it would + // invalidate that write. Unlike InsertChatMessages this does not touch + // chats.last_model_config_id, so callers do not need to restore it. + // + // cost_source is stored verbatim (no NULLIF) so it can never silently become + // NULL: an empty value fails the chat_messages cost_source CHECK and surfaces a + // caller bug instead of producing a row the history triggers treat as ordinary + // turn history. + InsertChatAccountingMessage(ctx context.Context, arg InsertChatAccountingMessageParams) (ChatMessage, error) // updated_at is the retention clock used by DeleteOldChatDebugRuns. // Set it on every write to keep retention semantics correct. InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index a35c1e771ef..11e0f6991bf 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12603,6 +12603,97 @@ func TestUpdateChatSummary(t *testing.T) { require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) } +func TestInsertChatAccountingMessage(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) + historyBefore := chat.HistoryVersion + + // The accounting row is tagged with cost_source on insert, so the + // AFTER STATEMENT history triggers treat it as non-history and must not + // advance chats.history_version. This is what keeps the turn summary + // write (guarded on history_version) from being invalidated by the very + // usage row recorded for that summary. + msg, err := db.InsertChatAccountingMessage(ctx, database.InsertChatAccountingMessageParams{ + ChatID: chat.ID, + CreatedBy: owner.ID, + ModelConfigID: modelCfg.ID, + Role: database.ChatMessageRoleAssistant, + Content: json.RawMessage(`[]`), + ContentVersion: 1, + Visibility: database.ChatMessageVisibilityModel, + TotalCostMicros: 1234, + CostSource: "summary", + }) + require.NoError(t, err) + require.Equal(t, sql.NullString{String: "summary", Valid: true}, msg.CostSource) + require.Equal(t, int64(1234), msg.TotalCostMicros.Int64) + + afterChat, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, historyBefore, afterChat.HistoryVersion, + "accounting-row insert must NOT advance history_version") + + // cost_source is stored verbatim (no NULLIF), so an empty value violates the + // chat_messages cost_source CHECK instead of silently becoming NULL. A NULL + // cost_source would make the history triggers treat the row as ordinary turn + // history and advance history_version, the exact bug this query prevents. + _, err = db.InsertChatAccountingMessage(ctx, database.InsertChatAccountingMessageParams{ + ChatID: chat.ID, + CreatedBy: owner.ID, + ModelConfigID: modelCfg.ID, + Role: database.ChatMessageRoleAssistant, + Content: json.RawMessage(`[]`), + ContentVersion: 1, + Visibility: database.ChatMessageVisibilityModel, + CostSource: "", + }) + require.Error(t, err, + "empty cost_source must fail the CHECK constraint, not silently insert NULL") +} + func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 904536e1a8f..89b9bebfa25 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 + 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 @@ -7512,6 +7512,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } @@ -7601,7 +7602,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 @@ -7651,6 +7652,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7667,7 +7669,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 @@ -7720,6 +7722,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7736,7 +7739,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 @@ -7802,6 +7805,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7818,7 +7822,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 @@ -7867,6 +7871,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7899,7 +7904,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 @@ -7973,6 +7978,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -9219,7 +9225,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 @@ -9264,6 +9270,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } @@ -9700,6 +9707,142 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat return i, err } +const insertChatAccountingMessage = `-- name: InsertChatAccountingMessage :one +INSERT INTO chat_messages ( + chat_id, + created_by, + api_key_id, + model_config_id, + role, + content, + content_version, + visibility, + input_tokens, + output_tokens, + total_tokens, + reasoning_tokens, + cache_creation_tokens, + cache_read_tokens, + context_limit, + compressed, + total_cost_micros, + runtime_ms, + provider_response_id, + cost_source +) VALUES ( + $1::uuid, + $2::uuid, + NULLIF($3::text, ''), + $4::uuid, + $5::chat_message_role, + $6::jsonb, + $7::smallint, + $8::chat_message_visibility, + NULLIF($9::bigint, 0), + NULLIF($10::bigint, 0), + NULLIF($11::bigint, 0), + NULLIF($12::bigint, 0), + NULLIF($13::bigint, 0), + NULLIF($14::bigint, 0), + NULLIF($15::bigint, 0), + $16::boolean, + NULLIF($17::bigint, 0), + NULLIF($18::bigint, 0), + NULLIF($19::text, ''), + $20::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 +` + +type InsertChatAccountingMessageParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` + APIKeyID string `db:"api_key_id" json:"api_key_id"` + ModelConfigID uuid.UUID `db:"model_config_id" json:"model_config_id"` + Role ChatMessageRole `db:"role" json:"role"` + Content json.RawMessage `db:"content" json:"content"` + ContentVersion int16 `db:"content_version" json:"content_version"` + Visibility ChatMessageVisibility `db:"visibility" json:"visibility"` + InputTokens int64 `db:"input_tokens" json:"input_tokens"` + OutputTokens int64 `db:"output_tokens" json:"output_tokens"` + TotalTokens int64 `db:"total_tokens" json:"total_tokens"` + ReasoningTokens int64 `db:"reasoning_tokens" json:"reasoning_tokens"` + CacheCreationTokens int64 `db:"cache_creation_tokens" json:"cache_creation_tokens"` + CacheReadTokens int64 `db:"cache_read_tokens" json:"cache_read_tokens"` + ContextLimit int64 `db:"context_limit" json:"context_limit"` + Compressed bool `db:"compressed" json:"compressed"` + TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"` + RuntimeMs int64 `db:"runtime_ms" json:"runtime_ms"` + ProviderResponseID string `db:"provider_response_id" json:"provider_response_id"` + CostSource string `db:"cost_source" json:"cost_source"` +} + +// Inserts a single hidden accounting row whose cost_source is set on the +// initial INSERT. Background summary and manual title generation use this to +// attribute their spend. Tagging cost_source at insert time (rather than via a +// follow-up UPDATE) is required so the AFTER STATEMENT history triggers see the +// row as an accounting row and do not advance chats.history_version: a turn +// summary write is guarded on history_version, and a row that advanced it would +// invalidate that write. Unlike InsertChatMessages this does not touch +// chats.last_model_config_id, so callers do not need to restore it. +// +// cost_source is stored verbatim (no NULLIF) so it can never silently become +// NULL: an empty value fails the chat_messages cost_source CHECK and surfaces a +// caller bug instead of producing a row the history triggers treat as ordinary +// turn history. +func (q *sqlQuerier) InsertChatAccountingMessage(ctx context.Context, arg InsertChatAccountingMessageParams) (ChatMessage, error) { + row := q.db.QueryRowContext(ctx, insertChatAccountingMessage, + arg.ChatID, + arg.CreatedBy, + arg.APIKeyID, + arg.ModelConfigID, + arg.Role, + arg.Content, + arg.ContentVersion, + arg.Visibility, + arg.InputTokens, + arg.OutputTokens, + arg.TotalTokens, + arg.ReasoningTokens, + arg.CacheCreationTokens, + arg.CacheReadTokens, + arg.ContextLimit, + arg.Compressed, + arg.TotalCostMicros, + arg.RuntimeMs, + arg.ProviderResponseID, + arg.CostSource, + ) + var i ChatMessage + err := row.Scan( + &i.ID, + &i.ChatID, + &i.ModelConfigID, + &i.CreatedAt, + &i.Role, + &i.Content, + &i.Visibility, + &i.InputTokens, + &i.OutputTokens, + &i.TotalTokens, + &i.ReasoningTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.ContextLimit, + &i.Compressed, + &i.CreatedBy, + &i.ContentVersion, + &i.TotalCostMicros, + &i.RuntimeMs, + &i.Deleted, + &i.ProviderResponseID, + &i.APIKeyID, + &i.Revision, + &i.CostSource, + ) + return i, err +} + const insertChatMessages = `-- name: InsertChatMessages :many WITH updated_chat AS ( UPDATE @@ -9771,7 +9914,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 { @@ -9849,6 +9992,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -11707,7 +11851,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 { @@ -11743,6 +11887,7 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index b6efa38b8ce..878a01d8f1b 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -868,6 +868,65 @@ SELECT RETURNING *; +-- name: InsertChatAccountingMessage :one +-- Inserts a single hidden accounting row whose cost_source is set on the +-- initial INSERT. Background summary and manual title generation use this to +-- attribute their spend. Tagging cost_source at insert time (rather than via a +-- follow-up UPDATE) is required so the AFTER STATEMENT history triggers see the +-- row as an accounting row and do not advance chats.history_version: a turn +-- summary write is guarded on history_version, and a row that advanced it would +-- invalidate that write. Unlike InsertChatMessages this does not touch +-- chats.last_model_config_id, so callers do not need to restore it. +-- +-- cost_source is stored verbatim (no NULLIF) so it can never silently become +-- NULL: an empty value fails the chat_messages cost_source CHECK and surfaces a +-- caller bug instead of producing a row the history triggers treat as ordinary +-- turn history. +INSERT INTO chat_messages ( + chat_id, + created_by, + api_key_id, + model_config_id, + role, + content, + content_version, + visibility, + input_tokens, + output_tokens, + total_tokens, + reasoning_tokens, + cache_creation_tokens, + cache_read_tokens, + context_limit, + compressed, + total_cost_micros, + runtime_ms, + provider_response_id, + cost_source +) VALUES ( + @chat_id::uuid, + @created_by::uuid, + NULLIF(@api_key_id::text, ''), + @model_config_id::uuid, + @role::chat_message_role, + @content::jsonb, + @content_version::smallint, + @visibility::chat_message_visibility, + NULLIF(@input_tokens::bigint, 0), + NULLIF(@output_tokens::bigint, 0), + NULLIF(@total_tokens::bigint, 0), + NULLIF(@reasoning_tokens::bigint, 0), + NULLIF(@cache_creation_tokens::bigint, 0), + NULLIF(@cache_read_tokens::bigint, 0), + NULLIF(@context_limit::bigint, 0), + @compressed::boolean, + NULLIF(@total_cost_micros::bigint, 0), + NULLIF(@runtime_ms::bigint, 0), + NULLIF(@provider_response_id::text, ''), + @cost_source::text +) +RETURNING *; + -- name: UpdateChatMessageByID :one UPDATE chat_messages diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index bc588ccd436..ff5a2632fa9 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -50,21 +50,21 @@ A chat's execution state lets the chat worker and the HTTP endpoints decide what The shorthands in the table below use the convention that the first 1 or 2 letters indicate the status, and then `1` or `0` indicate the presence or absence of queued messages. -| Shorthand | Status | Queue | Archived | Meaning | -| --- | --- | --- | --- | --- | -| `N` | - | - | - | Chat does not exist | -| `W` | `waiting` | empty | `false` | There's no work to be done by the chat worker | -| `E0` | `error` | empty | `false` | The worker encountered an unrecoverable error while processing the chat. There's no more work to be done by the chat worker | -| `E1` | `error` | non-empty | `false` | The worker encountered an unrecoverable error while processing the chat, and there's currently no work to be done by the chat worker. There's a queued message that should be processed once the error is cleared | -| `R0` | `running` | empty | `false` | Running state with no queued messages: a chat worker should be processing the chat | -| `R1` | `running` | non-empty | `false` | Running state with queued messages: a chat worker should be processing the chat, and there's a queued message that should be processed next | -| `I0` | `interrupting` | empty | `false` | The chat was interrupted by the user, and the chat worker should commit any partial message that had been generated before the interruption | -| `I1` | `interrupting` | non-empty | `false` | The chat was interrupted by the user, and the chat worker should commit any partial message that had been generated before the interruption, and there's a queued message that should be processed next | -| `A0` | `requires_action` | empty | `false` | The chat worker is waiting until the user submits tool results; this state is used only by the “dynamic tools” feature | -| `A1` | `requires_action` | non-empty | `false` | The chat worker is waiting until the user submits tool results, and there's a queued message that should be processed next; this state is used only by the “dynamic tools” feature | -| `XW` | `waiting` | empty | `true` | The chat was archived while it was in the `waiting` state, it will go back to `waiting` once unarchived | -| `XE0` | `error` | empty | `true` | The chat was archived while it was in the `error` state, it will go back to `error` once unarchived | -| `XE1` | `error` | non-empty | `true` | The chat was archived while it was in the `error` state, it will go back to `error` once unarchived, and there's a queued message that should be processed once the error is cleared | +| Shorthand | Status | Queue | Archived | Meaning | +|-----------|-------------------|-----------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `N` | - | - | - | Chat does not exist | +| `W` | `waiting` | empty | `false` | There's no work to be done by the chat worker | +| `E0` | `error` | empty | `false` | The worker encountered an unrecoverable error while processing the chat. There's no more work to be done by the chat worker | +| `E1` | `error` | non-empty | `false` | The worker encountered an unrecoverable error while processing the chat, and there's currently no work to be done by the chat worker. There's a queued message that should be processed once the error is cleared | +| `R0` | `running` | empty | `false` | Running state with no queued messages: a chat worker should be processing the chat | +| `R1` | `running` | non-empty | `false` | Running state with queued messages: a chat worker should be processing the chat, and there's a queued message that should be processed next | +| `I0` | `interrupting` | empty | `false` | The chat was interrupted by the user, and the chat worker should commit any partial message that had been generated before the interruption | +| `I1` | `interrupting` | non-empty | `false` | The chat was interrupted by the user, and the chat worker should commit any partial message that had been generated before the interruption, and there's a queued message that should be processed next | +| `A0` | `requires_action` | empty | `false` | The chat worker is waiting until the user submits tool results; this state is used only by the “dynamic tools” feature | +| `A1` | `requires_action` | non-empty | `false` | The chat worker is waiting until the user submits tool results, and there's a queued message that should be processed next; this state is used only by the “dynamic tools” feature | +| `XW` | `waiting` | empty | `true` | The chat was archived while it was in the `waiting` state, it will go back to `waiting` once unarchived | +| `XE0` | `error` | empty | `true` | The chat was archived while it was in the `error` state, it will go back to `error` once unarchived | +| `XE1` | `error` | non-empty | `true` | The chat was archived while it was in the `error` state, it will go back to `error` once unarchived, and there's a queued message that should be processed once the error is cleared | If these states seem arbitrary and abstract at this point, that's expected. Each one of these states is needed by some runtime component of chatd for some specific use case, and their purpose will emerge as we discuss the implementation of the HTTP endpoints and the chat worker. @@ -74,10 +74,10 @@ At a high-level, these states let us reason about what should be possible to hap A chat's ownership state lets the chat worker decide whether a chat can be acquired or not. It's decided by the `worker_id` field on the `chats` table. In total there are 2 ownership states. -| Shorthand | Worker ID | Meaning | -| --- | --- | --- | -| `U` | null | Unowned chat | -| `O` | not null | Owned chat | +| Shorthand | Worker ID | Meaning | +|-----------|-----------|--------------| +| `U` | null | Unowned chat | +| `O` | not null | Owned chat | ## Transitions @@ -259,6 +259,8 @@ Each row in `chat_messages` has a `revision` column. It stores the `chats.snapsh `chats.history_version` stores the latest `snapshot_version` in which chat message history changed. It starts at `0`, remains unchanged for non-history transitions, and is set to the current `snapshot_version` whenever a message is inserted or meaningfully updated. A newly created chat starts with `snapshot_version = 1`; because `Create` inserts initial history in that snapshot, the created chat's `history_version` becomes `1`. No-op message updates do not advance message `revision`, advance `history_version`, or reset `generation_attempt`. Whenever `history_version` changes, `generation_attempt` is reset to `0`; generation attempts are scoped to the current history version. +Hidden accounting rows are an exception to the rule above. Background summary and manual title generation record their spend as soft-deleted `chat_messages` rows tagged with a non-NULL `cost_source` (`summary` or `title`), inserted via `InsertChatAccountingMessage`. These rows are not durable conversation history, so the history triggers skip them: only rows with `cost_source IS NULL` advance `history_version`. `snapshot_version` still advances for every insert, so `history_version` trails `snapshot_version` after an accounting-only write and catches up on the next real message. This matters because a background summary write is itself guarded on `history_version`; if the accounting row recorded for that same summary advanced `history_version`, the guard would reject the summary write as stale even though no new turn occurred. + Message revision triggers depend on the transition invariant that `snapshot_version` is allocated immediately after the chat row is locked and before any message mutation happens. Runtime code must not assign `chat_messages.revision` directly. A `BEFORE INSERT` trigger assigns the current chat `snapshot_version` to the inserted message row and records the same value as the chat's latest history version: @@ -922,10 +924,10 @@ Initial null state: The loop has two operations: -| Operation | Description | -| --- | --- | -| `Sync(hints)` | Maybe fetch database state. If newer state is observed, emit required client events, update local cursors, and configure the relay target. Triggered by pubsub notifications and the sync poller. | -| `Part(history_version, generation_attempt, seq, content)` | Emit one live preview part. The operation succeeds only if the part matches local watermarks (history version, generation attempt, and seq). Triggered by the relay forwarder. | +| Operation | Description | +|-----------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Sync(hints)` | Maybe fetch database state. If newer state is observed, emit required client events, update local cursors, and configure the relay target. Triggered by pubsub notifications and the sync poller. | +| `Part(history_version, generation_attempt, seq, content)` | Emit one live preview part. The operation succeeds only if the part matches local watermarks (history version, generation attempt, and seq). Triggered by the relay forwarder. | The loop processes one operation at a time. It must not process another input halfway through a `Sync` or `Part`. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 1e306f4a9dc..9a7726b31fd 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2887,6 +2887,72 @@ 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. 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. +// +// cost_source is set on the initial INSERT via InsertChatAccountingMessage so +// the AFTER STATEMENT history triggers recognize the row as accounting spend +// and do not advance chats.history_version. Advancing it would invalidate the +// turn summary write guarded on history_version. The accounting insert also +// leaves chats.last_model_config_id untouched, so there is nothing to restore. +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 := "[]" + + message, err := tx.InsertChatAccountingMessage(ctx, database.InsertChatAccountingMessageParams{ + ChatID: lockedChat.ID, + CreatedBy: lockedChat.OwnerID, + APIKeyID: activeAPIKeyID, + ModelConfigID: modelConfig.ID, + Role: database.ChatMessageRoleAssistant, + Content: json.RawMessage(content), + ContentVersion: chatprompt.CurrentContentVersion, + Visibility: database.ChatMessageVisibilityModel, + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + ReasoningTokens: usage.ReasoningTokens, + CacheCreationTokens: usage.CacheCreationTokens, + CacheReadTokens: usage.CacheReadTokens, + ContextLimit: modelConfig.ContextLimit, + Compressed: false, + TotalCostMicros: ptr.NilToDefault(totalCostMicros, 0), + RuntimeMs: 0, + ProviderResponseID: "", + CostSource: costSource, + }) + if err != nil { + return xerrors.Errorf("insert %s usage message: %w", costSource, err) + } + if err := tx.SoftDeleteChatMessageByID(ctx, message.ID); err != nil { + return xerrors.Errorf("soft delete %s usage message: %w", costSource, err) + } + return nil +} + func recordManualTitleUsage( ctx context.Context, store database.Store, @@ -2901,26 +2967,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,43 +2975,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.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 +4926,13 @@ 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 @@ -4988,14 +5006,24 @@ func (p *Server) generateAndStoreChatSummary( return } - model, _, ok := p.resolveChatSummaryModel(authCtx, chat, runResult, logger) + model, modelConfig, ok := p.resolveChatSummaryModel(authCtx, chat, runResult, logger) if !ok { return } summaryCtx, cancelGen := context.WithTimeout(ctx, chatSummaryGenerateTimeout) defer cancelGen() - summary, _, genErr := generateChatSummary(summaryCtx, model, transcript) + 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", @@ -5106,6 +5134,37 @@ 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 3c15609a3c4..9bf83333b39 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3,6 +3,7 @@ package chatd import ( "context" "database/sql" + "encoding/json" "strings" "sync" "testing" @@ -924,12 +925,13 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { lockTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) - usageTx.EXPECT().InsertChatMessages(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatMessagesParams{})).DoAndReturn( - func(_ context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - require.Equal(t, []uuid.UUID{ownerID}, arg.CreatedBy) - require.Equal(t, []uuid.UUID{modelConfigID}, arg.ModelConfigID) - require.Equal(t, []string{"[]"}, arg.Content) - return []database.ChatMessage{{ID: 91}}, nil + usageTx.EXPECT().InsertChatAccountingMessage(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatAccountingMessageParams{})).DoAndReturn( + func(_ context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { + require.Equal(t, ownerID, arg.CreatedBy) + require.Equal(t, modelConfigID, arg.ModelConfigID) + require.Equal(t, json.RawMessage("[]"), arg.Content) + require.Equal(t, "title", arg.CostSource) + return database.ChatMessage{ID: 91}, nil }, ) usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) @@ -1104,12 +1106,13 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t }) usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(lockedChat, nil) - usageTx.EXPECT().InsertChatMessages(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatMessagesParams{})).DoAndReturn( - func(_ context.Context, arg database.InsertChatMessagesParams) ([]database.ChatMessage, error) { - require.Equal(t, []uuid.UUID{ownerID}, arg.CreatedBy) - require.Equal(t, []uuid.UUID{modelConfigID}, arg.ModelConfigID) - require.Equal(t, []string{"[]"}, arg.Content) - return []database.ChatMessage{{ID: 91}}, nil + usageTx.EXPECT().InsertChatAccountingMessage(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatAccountingMessageParams{})).DoAndReturn( + func(_ context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { + require.Equal(t, ownerID, arg.CreatedBy) + require.Equal(t, modelConfigID, arg.ModelConfigID) + require.Equal(t, json.RawMessage("[]"), arg.Content) + require.Equal(t, "title", arg.CostSource) + return database.ChatMessage{ID: 91}, nil }, ) usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(91)).Return(nil) @@ -1147,6 +1150,70 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t } } +// TestRecordChatSummaryUsage_InsertsAccountingRow verifies the background +// summary path persists its spend through InsertChatAccountingMessage tagged +// with cost_source='summary' and soft-deletes the row, mirroring the manual +// title path. Using the dedicated accounting insert (rather than +// InsertChatMessages plus a follow-up tag) is what keeps the row from advancing +// history_version. +func TestRecordChatSummaryUsage_InsertsAccountingRow(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + usageTx := dbmock.NewMockStore(ctrl) + + ownerID := uuid.New() + chatID := uuid.New() + modelConfigID := uuid.New() + activeAPIKeyID := "key-" + uuid.NewString() + + chat := database.Chat{ + ID: chatID, + OwnerID: ownerID, + LastModelConfigID: modelConfigID, + Status: database.ChatStatusRunning, + } + modelConfig := database.ChatModelConfig{ + ID: modelConfigID, + Provider: "openai", + Model: "gpt-4o-mini", + ContextLimit: 8192, + } + usage := fantasy.Usage{ + InputTokens: 120, + OutputTokens: 45, + TotalTokens: 165, + } + + db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( + func(fn func(database.Store) error, opts *database.TxOptions) error { + require.Nil(t, opts) + return fn(usageTx) + }, + ) + usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) + usageTx.EXPECT().InsertChatAccountingMessage(gomock.Any(), gomock.AssignableToTypeOf(database.InsertChatAccountingMessageParams{})).DoAndReturn( + func(_ context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { + require.Equal(t, chatID, arg.ChatID) + require.Equal(t, ownerID, arg.CreatedBy) + require.Equal(t, modelConfigID, arg.ModelConfigID) + require.Equal(t, activeAPIKeyID, arg.APIKeyID) + require.Equal(t, json.RawMessage("[]"), arg.Content) + require.Equal(t, usage.InputTokens, arg.InputTokens) + require.Equal(t, usage.OutputTokens, arg.OutputTokens) + require.Equal(t, chatCostSourceSummary, arg.CostSource) + return database.ChatMessage{ID: 77}, nil + }, + ) + usageTx.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), int64(77)).Return(nil) + + gotChat, err := recordChatSummaryUsage(ctx, db, chat, modelConfig, usage, activeAPIKeyID) + require.NoError(t, err) + require.Equal(t, chat, gotChat) +} + func TestResolveUserProviderAPIKeys_StripsDisabledFallbackKeys(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatstate/trigger_test.go b/coderd/x/chatd/chatstate/trigger_test.go index dc31651ac24..7251f47eea7 100644 --- a/coderd/x/chatd/chatstate/trigger_test.go +++ b/coderd/x/chatd/chatstate/trigger_test.go @@ -261,6 +261,69 @@ func TestNoopMessageUpdateDoesNotAdvanceHistoryVersion(t *testing.T) { "no-op update must NOT advance message revision") } +// TestAccountingMessageDoesNotAdvanceHistoryVersion verifies that +// inserting or soft-deleting a hidden accounting row (cost_source set, +// e.g. background summary or manual title spend) does NOT advance +// chats.history_version, while an ordinary turn message (cost_source +// NULL) still does. Accounting rows are not durable conversation +// history, so they must not invalidate history_version freshness +// guards such as the ones used by the summary and last_turn_summary +// writes. +func TestAccountingMessageDoesNotAdvanceHistoryVersion(t *testing.T) { + t.Parallel() + tf := newTriggerFixture(t) + f := tf.f + ctx := testutil.Context(t, testutil.WaitShort) + + created := createTestChat(t, f) + content := userMessageContent(t, "accounting-row") + + // Bump snapshot so history_version trails it; an accounting-row + // write must leave history_version untouched. + bumped, err := f.DB.LockChatAndBumpSnapshotVersion(ctx, created.Chat.ID) + require.NoError(t, err) + historyBefore := bumped.HistoryVersion + require.NotEqual(t, bumped.SnapshotVersion, historyBefore, + "snapshot bump leaves history_version trailing") + + // Insert a hidden accounting row (cost_source = 'summary'). + _, err = tf.sqlDB.ExecContext(ctx, ` + INSERT INTO chat_messages (chat_id, role, content, content_version, visibility, cost_source) + VALUES ($1, 'assistant', $2::jsonb, $3, 'model', 'summary') + `, created.Chat.ID, string(content), int(chatprompt.CurrentContentVersion)) + require.NoError(t, err) + + afterInsert, err := f.DB.GetChatByID(ctx, created.Chat.ID) + require.NoError(t, err) + require.Equal(t, historyBefore, afterInsert.HistoryVersion, + "accounting-row insert must NOT advance history_version") + + // Soft-deleting the accounting row must also leave history_version + // untouched (the summary writer soft-deletes its usage row). + _, err = tf.sqlDB.ExecContext(ctx, ` + UPDATE chat_messages SET deleted = true WHERE chat_id = $1 AND cost_source = 'summary' + `, created.Chat.ID) + require.NoError(t, err) + + afterDelete, err := f.DB.GetChatByID(ctx, created.Chat.ID) + require.NoError(t, err) + require.Equal(t, historyBefore, afterDelete.HistoryVersion, + "accounting-row soft delete must NOT advance history_version") + + // Positive control: an ordinary turn message (cost_source NULL) + // still advances history_version to the current snapshot. + _, err = tf.sqlDB.ExecContext(ctx, ` + INSERT INTO chat_messages (chat_id, role, content, content_version, visibility) + VALUES ($1, 'assistant', $2::jsonb, $3, 'both') + `, created.Chat.ID, string(content), int(chatprompt.CurrentContentVersion)) + require.NoError(t, err) + + afterReal, err := f.DB.GetChatByID(ctx, created.Chat.ID) + require.NoError(t, err) + require.Equal(t, afterReal.SnapshotVersion, afterReal.HistoryVersion, + "ordinary message must advance history_version to snapshot_version") +} + // Queue version triggers // TestQueueInsertUpdatesQueueVersion verifies that an INSERT into From 785ef29099476fbf1efe8d96f4cd222a6fa224be Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 15:05:42 +0000 Subject: [PATCH 8/9] fix(coderd): restore InsertChatAccountingMessage dbauthz authorization The base-branch merge overwrote the InsertChatAccountingMessage dbauthz method with a panic("not implemented") stub. Production paths are dbauthz-wrapped, so recordChatSummaryUsage and recordManualTitleUsage would panic at runtime. Restore the authorization (authorize update on the parent chat, delegating to the underlying store) and the dbauthz test. Also correct the snapshot_version note in ARCHITECTURE.md and the 000535 migration comment: accounting inserts do not bump snapshot_version, so an accounting-only write leaves both version columns unchanged. --- coderd/database/dbauthz/dbauthz.go | 10 +++++++++- coderd/database/dbauthz/dbauthz_test.go | 8 ++++++++ .../000535_chat_history_ignore_accounting.up.sql | 5 ++--- coderd/x/chatd/ARCHITECTURE.md | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e9af712f374..0996399e120 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5856,7 +5856,15 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) } func (q *querier) InsertChatAccountingMessage(ctx context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { - panic("not implemented") + // Authorize update on the parent chat, like the other chat-message writes. + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return database.ChatMessage{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return database.ChatMessage{}, err + } + return q.db.InsertChatAccountingMessage(ctx, arg) } func (q *querier) InsertChatDebugRun(ctx context.Context, arg database.InsertChatDebugRunParams) (database.ChatDebugRun, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a5757923844..ab7ce2c5cb2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1233,6 +1233,14 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().InsertChat(gomock.Any(), arg).Return(chat, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()).InOrg(arg.OrganizationID), policy.ActionCreate).Returns(chat) })) + s.Run("InsertChatAccountingMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + arg := testutil.Fake(s.T(), faker, database.InsertChatAccountingMessageParams{ChatID: chat.ID}) + msg := testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID}) + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().InsertChatAccountingMessage(gomock.Any(), arg).Return(msg, nil).AnyTimes() + check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(msg) + })) s.Run("InsertChatFile", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := testutil.Fake(s.T(), faker, database.InsertChatFileParams{}) file := testutil.Fake(s.T(), faker, database.InsertChatFileRow{OwnerID: arg.OwnerID, OrganizationID: arg.OrganizationID}) diff --git a/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql index e9f17b43e2a..36333bf33be 100644 --- a/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql @@ -18,9 +18,8 @@ ALTER TABLE chat_messages -- -- Recreate the AFTER STATEMENT history triggers so only rows with -- cost_source IS NULL (ordinary turn history) advance history_version. --- snapshot_version still advances for every change, so history_version --- correctly trails behind it after an accounting-only write and catches up on --- the next real message. +-- The accounting insert does not bump snapshot_version, so an accounting-only +-- write leaves both snapshot_version and history_version unchanged. CREATE OR REPLACE FUNCTION update_chat_history_after_message_insert() RETURNS trigger AS $$ BEGIN diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index ff5a2632fa9..9748316516f 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -259,7 +259,7 @@ Each row in `chat_messages` has a `revision` column. It stores the `chats.snapsh `chats.history_version` stores the latest `snapshot_version` in which chat message history changed. It starts at `0`, remains unchanged for non-history transitions, and is set to the current `snapshot_version` whenever a message is inserted or meaningfully updated. A newly created chat starts with `snapshot_version = 1`; because `Create` inserts initial history in that snapshot, the created chat's `history_version` becomes `1`. No-op message updates do not advance message `revision`, advance `history_version`, or reset `generation_attempt`. Whenever `history_version` changes, `generation_attempt` is reset to `0`; generation attempts are scoped to the current history version. -Hidden accounting rows are an exception to the rule above. Background summary and manual title generation record their spend as soft-deleted `chat_messages` rows tagged with a non-NULL `cost_source` (`summary` or `title`), inserted via `InsertChatAccountingMessage`. These rows are not durable conversation history, so the history triggers skip them: only rows with `cost_source IS NULL` advance `history_version`. `snapshot_version` still advances for every insert, so `history_version` trails `snapshot_version` after an accounting-only write and catches up on the next real message. This matters because a background summary write is itself guarded on `history_version`; if the accounting row recorded for that same summary advanced `history_version`, the guard would reject the summary write as stale even though no new turn occurred. +Hidden accounting rows are an exception to the rule above. Background summary and manual title generation record their spend as soft-deleted `chat_messages` rows tagged with a non-NULL `cost_source` (`summary` or `title`), inserted via `InsertChatAccountingMessage`. These rows are not durable conversation history, so the history triggers skip them: only rows with `cost_source IS NULL` advance `history_version`. The accounting insert does not bump `snapshot_version` either, so an accounting-only write leaves both `snapshot_version` and `history_version` unchanged. This matters because a background summary write is itself guarded on `history_version`; if the accounting row recorded for that same summary advanced `history_version`, the guard would reject the summary write as stale even though no new turn occurred. Message revision triggers depend on the transition invariant that `snapshot_version` is allocated immediately after the chat row is locked and before any message mutation happens. Runtime code must not assign `chat_messages.revision` directly. From 6e411501e106a99384a357a559e872e5b437e607 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 30 Jun 2026 05:12:46 +0000 Subject: [PATCH 9/9] chore(coderd): condense chat history accounting comments Trim the verbose, duplicated rationale comments added for the chat accounting work down to short, single-purpose comments. The full explanation of why accounting rows must not advance history_version now lives once in coderd/x/chatd/ARCHITECTURE.md; the code, SQL, migration, and test comments keep only the non-obvious essentials. --- coderd/database/dbauthz/dbauthz.go | 1 - ...35_chat_history_ignore_accounting.down.sql | 3 +- ...0535_chat_history_ignore_accounting.up.sql | 26 +++++----------- coderd/database/querier.go | 17 +++------- coderd/database/querier_test.go | 13 +++----- coderd/database/queries.sql.go | 17 +++------- coderd/database/queries/chats.sql | 17 +++------- coderd/x/chatd/chatd.go | 31 ++++++------------- coderd/x/chatd/chatd_internal_test.go | 9 ++---- coderd/x/chatd/chatstate/trigger_test.go | 20 ++++-------- 10 files changed, 43 insertions(+), 111 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 0996399e120..8662290ce74 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5856,7 +5856,6 @@ func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) } func (q *querier) InsertChatAccountingMessage(ctx context.Context, arg database.InsertChatAccountingMessageParams) (database.ChatMessage, error) { - // Authorize update on the parent chat, like the other chat-message writes. chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { return database.ChatMessage{}, err diff --git a/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql b/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql index 3435be48e22..aa1e596f85c 100644 --- a/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql @@ -1,5 +1,4 @@ --- Restore the original history triggers that advance history_version for every --- chat_messages change, including hidden accounting rows. +-- Restore the original triggers that advance history_version for every change. CREATE OR REPLACE FUNCTION update_chat_history_after_message_insert() RETURNS trigger AS $$ BEGIN diff --git a/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql index 36333bf33be..25c21888f5b 100644 --- a/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql @@ -1,25 +1,13 @@ --- 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). +-- cost_source attributes spend to a feature: NULL is ordinary turn spend, +-- 'summary' and 'title' tag the hidden accounting rows. The CHECK bounds it to +-- that closed set so a typo cannot silently corrupt cost attribution. ALTER TABLE chat_messages ADD COLUMN cost_source TEXT CHECK (cost_source IN ('summary', 'title')); --- Background summary and manual title generation write hidden, soft-deleted --- chat_messages accounting rows tagged with cost_source to attribute their --- spend. Those rows are not durable conversation history, so they must not --- advance chats.history_version. Otherwise a summary write guarded on --- history_version is invalidated by the very accounting row recorded for that --- same summary, and the write (and the last_turn_summary write racing behind --- it) is rejected as stale even when no new turn occurred. --- --- Recreate the AFTER STATEMENT history triggers so only rows with --- cost_source IS NULL (ordinary turn history) advance history_version. --- The accounting insert does not bump snapshot_version, so an accounting-only --- write leaves both snapshot_version and history_version unchanged. +-- Recreate the AFTER STATEMENT history triggers so only rows with cost_source +-- IS NULL (ordinary turn history) advance history_version. Hidden accounting +-- rows (cost_source set) must not, or the accounting row recorded for a summary +-- would invalidate that same summary's history_version-guarded write. CREATE OR REPLACE FUNCTION update_chat_history_after_message_insert() RETURNS trigger AS $$ BEGIN diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8ec915d293c..2e92db101e1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1023,19 +1023,10 @@ type sqlcQuerier interface { InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) - // Inserts a single hidden accounting row whose cost_source is set on the - // initial INSERT. Background summary and manual title generation use this to - // attribute their spend. Tagging cost_source at insert time (rather than via a - // follow-up UPDATE) is required so the AFTER STATEMENT history triggers see the - // row as an accounting row and do not advance chats.history_version: a turn - // summary write is guarded on history_version, and a row that advanced it would - // invalidate that write. Unlike InsertChatMessages this does not touch - // chats.last_model_config_id, so callers do not need to restore it. - // - // cost_source is stored verbatim (no NULLIF) so it can never silently become - // NULL: an empty value fails the chat_messages cost_source CHECK and surfaces a - // caller bug instead of producing a row the history triggers treat as ordinary - // turn history. + // Inserts a single hidden accounting row (background summary or manual title + // spend). cost_source is set on the INSERT so the history triggers skip the row + // and do not advance history_version, and is stored verbatim so an empty value + // fails the cost_source CHECK instead of silently becoming NULL. InsertChatAccountingMessage(ctx context.Context, arg InsertChatAccountingMessageParams) (ChatMessage, error) // updated_at is the retention clock used by DeleteOldChatDebugRuns. // Set it on every write to keep retention semantics correct. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 11e0f6991bf..fa2f918f45e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12651,11 +12651,8 @@ func TestInsertChatAccountingMessage(t *testing.T) { require.NoError(t, err) historyBefore := chat.HistoryVersion - // The accounting row is tagged with cost_source on insert, so the - // AFTER STATEMENT history triggers treat it as non-history and must not - // advance chats.history_version. This is what keeps the turn summary - // write (guarded on history_version) from being invalidated by the very - // usage row recorded for that summary. + // Accounting rows are tagged with cost_source on insert, so the history + // triggers must not advance history_version. msg, err := db.InsertChatAccountingMessage(ctx, database.InsertChatAccountingMessageParams{ ChatID: chat.ID, CreatedBy: owner.ID, @@ -12676,10 +12673,8 @@ func TestInsertChatAccountingMessage(t *testing.T) { require.Equal(t, historyBefore, afterChat.HistoryVersion, "accounting-row insert must NOT advance history_version") - // cost_source is stored verbatim (no NULLIF), so an empty value violates the - // chat_messages cost_source CHECK instead of silently becoming NULL. A NULL - // cost_source would make the history triggers treat the row as ordinary turn - // history and advance history_version, the exact bug this query prevents. + // cost_source is stored verbatim (no NULLIF): an empty value must fail the + // cost_source CHECK rather than silently becoming NULL. _, err = db.InsertChatAccountingMessage(ctx, database.InsertChatAccountingMessageParams{ ChatID: chat.ID, CreatedBy: owner.ID, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 89b9bebfa25..9e2b3c4bb59 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9777,19 +9777,10 @@ type InsertChatAccountingMessageParams struct { CostSource string `db:"cost_source" json:"cost_source"` } -// Inserts a single hidden accounting row whose cost_source is set on the -// initial INSERT. Background summary and manual title generation use this to -// attribute their spend. Tagging cost_source at insert time (rather than via a -// follow-up UPDATE) is required so the AFTER STATEMENT history triggers see the -// row as an accounting row and do not advance chats.history_version: a turn -// summary write is guarded on history_version, and a row that advanced it would -// invalidate that write. Unlike InsertChatMessages this does not touch -// chats.last_model_config_id, so callers do not need to restore it. -// -// cost_source is stored verbatim (no NULLIF) so it can never silently become -// NULL: an empty value fails the chat_messages cost_source CHECK and surfaces a -// caller bug instead of producing a row the history triggers treat as ordinary -// turn history. +// Inserts a single hidden accounting row (background summary or manual title +// spend). cost_source is set on the INSERT so the history triggers skip the row +// and do not advance history_version, and is stored verbatim so an empty value +// fails the cost_source CHECK instead of silently becoming NULL. func (q *sqlQuerier) InsertChatAccountingMessage(ctx context.Context, arg InsertChatAccountingMessageParams) (ChatMessage, error) { row := q.db.QueryRowContext(ctx, insertChatAccountingMessage, arg.ChatID, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 878a01d8f1b..3ef81a70975 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -869,19 +869,10 @@ RETURNING *; -- name: InsertChatAccountingMessage :one --- Inserts a single hidden accounting row whose cost_source is set on the --- initial INSERT. Background summary and manual title generation use this to --- attribute their spend. Tagging cost_source at insert time (rather than via a --- follow-up UPDATE) is required so the AFTER STATEMENT history triggers see the --- row as an accounting row and do not advance chats.history_version: a turn --- summary write is guarded on history_version, and a row that advanced it would --- invalidate that write. Unlike InsertChatMessages this does not touch --- chats.last_model_config_id, so callers do not need to restore it. --- --- cost_source is stored verbatim (no NULLIF) so it can never silently become --- NULL: an empty value fails the chat_messages cost_source CHECK and surfaces a --- caller bug instead of producing a row the history triggers treat as ordinary --- turn history. +-- Inserts a single hidden accounting row (background summary or manual title +-- spend). cost_source is set on the INSERT so the history triggers skip the row +-- and do not advance history_version, and is stored verbatim so an empty value +-- fails the cost_source CHECK instead of silently becoming NULL. INSERT INTO chat_messages ( chat_id, created_by, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9a7726b31fd..6a18fa73b16 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2887,17 +2887,10 @@ 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. 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. -// -// cost_source is set on the initial INSERT via InsertChatAccountingMessage so -// the AFTER STATEMENT history triggers recognize the row as accounting spend -// and do not advance chats.history_version. Advancing it would invalidate the -// turn summary write guarded on history_version. The accounting insert also -// leaves chats.last_model_config_id untouched, so there is nothing to restore. +// recordHiddenUsageMessageTx records non-turn spend (summary or title) as a +// hidden, soft-deleted accounting row tagged with costSource. cost_source is set +// on the INSERT so the history triggers skip the row (see +// InsertChatAccountingMessage). func recordHiddenUsageMessageTx( ctx context.Context, tx database.Store, @@ -2918,8 +2911,8 @@ func recordHiddenUsageMessageTx( callConfig.Cost, ) - // MarshalParts returns a null NullRawMessage for empty slices, which becomes - // an empty string that PostgreSQL rejects as invalid JSON. + // Marshaling empty parts yields an empty string Postgres rejects as invalid + // JSON, so use a literal empty array. content := "[]" message, err := tx.InsertChatAccountingMessage(ctx, database.InsertChatAccountingMessageParams{ @@ -4926,8 +4919,7 @@ 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. +// chatCostSource tags hidden accounting rows; ordinary turn spend stays NULL. const ( chatCostSourceSummary = "summary" chatCostSourceTitle = "title" @@ -5015,8 +5007,7 @@ func (p *Server) generateAndStoreChatSummary( 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. + // Record cost whenever the model reported usage, even on generation failure. if usage != (fantasy.Usage{}) { activeAPIKeyID, _ := activeTurnAPIKeyIDFromMessages(messages) if _, recordErr := recordChatSummaryUsage(authCtx, p.db, chat, modelConfig, usage, activeAPIKeyID); recordErr != nil { @@ -5134,10 +5125,8 @@ 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. +// recordChatSummaryUsage records background-summary spend via +// recordHiddenUsageMessageTx; it never updates the chat title. func recordChatSummaryUsage( ctx context.Context, store database.Store, diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 9bf83333b39..2f6fc4ad955 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1150,12 +1150,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t } } -// TestRecordChatSummaryUsage_InsertsAccountingRow verifies the background -// summary path persists its spend through InsertChatAccountingMessage tagged -// with cost_source='summary' and soft-deletes the row, mirroring the manual -// title path. Using the dedicated accounting insert (rather than -// InsertChatMessages plus a follow-up tag) is what keeps the row from advancing -// history_version. +// TestRecordChatSummaryUsage_InsertsAccountingRow verifies the background summary +// path persists spend via InsertChatAccountingMessage tagged cost_source='summary' +// and soft-deletes the row. func TestRecordChatSummaryUsage_InsertsAccountingRow(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatstate/trigger_test.go b/coderd/x/chatd/chatstate/trigger_test.go index 7251f47eea7..437be408be6 100644 --- a/coderd/x/chatd/chatstate/trigger_test.go +++ b/coderd/x/chatd/chatstate/trigger_test.go @@ -261,14 +261,9 @@ func TestNoopMessageUpdateDoesNotAdvanceHistoryVersion(t *testing.T) { "no-op update must NOT advance message revision") } -// TestAccountingMessageDoesNotAdvanceHistoryVersion verifies that -// inserting or soft-deleting a hidden accounting row (cost_source set, -// e.g. background summary or manual title spend) does NOT advance -// chats.history_version, while an ordinary turn message (cost_source -// NULL) still does. Accounting rows are not durable conversation -// history, so they must not invalidate history_version freshness -// guards such as the ones used by the summary and last_turn_summary -// writes. +// TestAccountingMessageDoesNotAdvanceHistoryVersion verifies that inserting or +// soft-deleting a hidden accounting row (cost_source set) leaves history_version +// unchanged, while an ordinary turn message (cost_source NULL) still advances it. func TestAccountingMessageDoesNotAdvanceHistoryVersion(t *testing.T) { t.Parallel() tf := newTriggerFixture(t) @@ -278,8 +273,7 @@ func TestAccountingMessageDoesNotAdvanceHistoryVersion(t *testing.T) { created := createTestChat(t, f) content := userMessageContent(t, "accounting-row") - // Bump snapshot so history_version trails it; an accounting-row - // write must leave history_version untouched. + // Bump snapshot so history_version trails it. bumped, err := f.DB.LockChatAndBumpSnapshotVersion(ctx, created.Chat.ID) require.NoError(t, err) historyBefore := bumped.HistoryVersion @@ -298,8 +292,7 @@ func TestAccountingMessageDoesNotAdvanceHistoryVersion(t *testing.T) { require.Equal(t, historyBefore, afterInsert.HistoryVersion, "accounting-row insert must NOT advance history_version") - // Soft-deleting the accounting row must also leave history_version - // untouched (the summary writer soft-deletes its usage row). + // Soft-deleting the accounting row must also leave history_version untouched. _, err = tf.sqlDB.ExecContext(ctx, ` UPDATE chat_messages SET deleted = true WHERE chat_id = $1 AND cost_source = 'summary' `, created.Chat.ID) @@ -310,8 +303,7 @@ func TestAccountingMessageDoesNotAdvanceHistoryVersion(t *testing.T) { require.Equal(t, historyBefore, afterDelete.HistoryVersion, "accounting-row soft delete must NOT advance history_version") - // Positive control: an ordinary turn message (cost_source NULL) - // still advances history_version to the current snapshot. + // Positive control: an ordinary turn message still advances history_version. _, err = tf.sqlDB.ExecContext(ctx, ` INSERT INTO chat_messages (chat_id, role, content, content_version, visibility) VALUES ($1, 'assistant', $2::jsonb, $3, 'both')