diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fdbdb9f46dddf..e40acc93de226 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16725,6 +16725,10 @@ const docTemplate = `{ "status": { "$ref": "#/definitions/codersdk.ChatStatus" }, + "summary": { + "description": "Summary is the persisted whole-chat summary, generated asynchronously in\nthe background. It is nil until the first summary has been produced.", + "type": "string" + }, "title": { "type": "string" }, @@ -17841,6 +17845,7 @@ const docTemplate = `{ "enum": [ "status_change", "summary_change", + "chat_summary_change", "title_change", "created", "deleted", @@ -17851,6 +17856,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 93bfcc07bdf04..36431abea5af4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15029,6 +15029,10 @@ "status": { "$ref": "#/definitions/codersdk.ChatStatus" }, + "summary": { + "description": "Summary is the persisted whole-chat summary, generated asynchronously in\nthe background. It is nil until the first summary has been produced.", + "type": "string" + }, "title": { "type": "string" }, @@ -16092,6 +16096,7 @@ "enum": [ "status_change", "summary_change", + "chat_summary_change", "title_change", "created", "deleted", @@ -16102,6 +16107,7 @@ "x-enum-varnames": [ "ChatWatchEventKindStatusChange", "ChatWatchEventKindSummaryChange", + "ChatWatchEventKindChatSummaryChange", "ChatWatchEventKindTitleChange", "ChatWatchEventKindCreated", "ChatWatchEventKindDeleted", diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 76b350816d757..0927a761b2bbe 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/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 2e4698b67c870..d13c5a4f751c5 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 52eda2adc55c4..2734cf5eda7ef 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 d6e5a27e77f7b..8662290ce7425 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5855,6 +5855,17 @@ 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) { + 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) { chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { @@ -7280,6 +7291,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 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 865d075f0be16..ab7ce2c5cb230 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}) @@ -1910,6 +1918,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 11e785f8ab332..d3c00bd4971ab 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) @@ -5226,6 +5234,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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 248c26d0a17aa..95b42c70e4956 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() @@ -9844,6 +9859,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() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index a984421d82858..ac7cc3d2aa622 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 @@ -2020,6 +2025,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))), @@ -2120,6 +2127,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/000534_chat_summary.down.sql b/coderd/database/migrations/000534_chat_summary.down.sql new file mode 100644 index 0000000000000..dda181ed92d01 --- /dev/null +++ b/coderd/database/migrations/000534_chat_summary.down.sql @@ -0,0 +1,55 @@ +-- Drop the view before the columns it references, then recreate it without +-- the summary columns, matching the pre-000534 chats_expanded definition. +DROP VIEW IF EXISTS chats_expanded; + +ALTER TABLE chats + DROP COLUMN summary, + DROP COLUMN summary_generated_at; + +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/000534_chat_summary.up.sql b/coderd/database/migrations/000534_chat_summary.up.sql new file mode 100644 index 0000000000000..3c57bd9eb6d35 --- /dev/null +++ b/coderd/database/migrations/000534_chat_summary.up.sql @@ -0,0 +1,61 @@ +-- 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; + +-- 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/migrations/000535_chat_history_ignore_accounting.down.sql b/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql new file mode 100644 index 0000000000000..aa1e596f85cdd --- /dev/null +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.down.sql @@ -0,0 +1,42 @@ +-- 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 + 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 0000000000000..25c21888f5b87 --- /dev/null +++ b/coderd/database/migrations/000535_chat_history_ignore_accounting.up.sql @@ -0,0 +1,51 @@ +-- 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')); + +-- 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 + 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/modelqueries.go b/coderd/database/modelqueries.go index e5618d5564e41..b385c8d56f882 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 93ae445581573..c7114b469d580 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4798,6 +4798,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"` @@ -4949,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 { @@ -5029,7 +5032,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 d3b745e3f7974..2e92db101e1db 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1023,6 +1023,11 @@ 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 (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. InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) @@ -1375,6 +1380,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) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b6f1ca0ecce3c..fa2f918f45e48 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12474,6 +12474,221 @@ 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 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 + + // 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, + 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): 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, + 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 44c0b73b200ca..9e2b3c4bb5978 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5522,7 +5522,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 @@ -5553,6 +5553,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, @@ -5574,7 +5576,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 ` @@ -5623,6 +5625,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, @@ -5777,7 +5781,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 @@ -5808,6 +5812,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, @@ -5829,7 +5835,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 ` @@ -5871,6 +5877,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, @@ -5937,10 +5945,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, @@ -5999,6 +6007,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"` } @@ -6060,6 +6070,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 @@ -6336,7 +6348,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 @@ -6384,6 +6396,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, @@ -6416,7 +6430,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 ( @@ -6476,6 +6490,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"` @@ -6535,6 +6551,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, @@ -6589,7 +6607,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 ` @@ -6625,6 +6643,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, @@ -6647,7 +6667,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 @@ -6681,6 +6701,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, @@ -6702,7 +6724,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 ` @@ -6737,6 +6759,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, @@ -6759,7 +6783,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 @@ -6793,6 +6817,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, @@ -6814,7 +6840,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 ` @@ -6849,6 +6875,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, @@ -7449,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 @@ -7484,6 +7512,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } @@ -7573,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 @@ -7623,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 } @@ -7639,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 @@ -7692,6 +7722,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7708,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 @@ -7774,6 +7805,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7790,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 @@ -7839,6 +7871,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -7871,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 @@ -7945,6 +7978,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -8313,7 +8347,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 @@ -8377,6 +8411,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"` @@ -8445,6 +8481,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, @@ -8487,7 +8525,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 @@ -8726,6 +8764,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, @@ -8759,7 +8799,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 @@ -8809,6 +8849,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, @@ -8840,7 +8882,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 @@ -8883,6 +8925,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, @@ -8914,7 +8958,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[]) @@ -8958,6 +9002,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, @@ -9058,7 +9104,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 @@ -9130,6 +9176,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, @@ -9177,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 @@ -9222,13 +9270,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 @@ -9288,6 +9337,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, @@ -9509,7 +9560,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 @@ -9540,6 +9591,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, @@ -9561,7 +9614,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 ` @@ -9632,6 +9685,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, @@ -9652,6 +9707,133 @@ 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 (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, + 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 @@ -9723,7 +9905,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 { @@ -9801,6 +9983,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ); err != nil { return nil, err } @@ -10133,7 +10316,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 @@ -10164,6 +10347,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, @@ -10184,7 +10369,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 ` @@ -10223,6 +10408,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, @@ -10569,7 +10756,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 @@ -10600,6 +10787,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, @@ -10621,7 +10810,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 ` @@ -10667,6 +10856,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, @@ -10785,7 +10976,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 @@ -10816,6 +11007,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, @@ -10837,7 +11030,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 ` @@ -10878,6 +11071,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, @@ -10907,7 +11102,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 @@ -10938,6 +11133,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, @@ -10959,7 +11156,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 ` @@ -10999,6 +11196,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, @@ -11032,7 +11231,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 @@ -11063,6 +11262,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, @@ -11083,7 +11284,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 ` @@ -11140,6 +11341,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, @@ -11214,7 +11417,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 @@ -11245,6 +11448,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, @@ -11266,7 +11471,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 ` @@ -11306,6 +11511,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, @@ -11335,7 +11542,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 @@ -11366,6 +11573,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, @@ -11387,7 +11596,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 ` @@ -11427,6 +11636,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, @@ -11506,7 +11717,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 @@ -11537,6 +11748,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, @@ -11558,7 +11771,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 ` @@ -11598,6 +11811,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, @@ -11627,7 +11842,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 { @@ -11663,6 +11878,7 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.CostSource, ) return i, err } @@ -11747,7 +11963,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 @@ -11778,6 +11994,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, @@ -11799,7 +12017,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 ` @@ -11839,6 +12057,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, @@ -11866,7 +12086,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 @@ -11897,6 +12117,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, @@ -11917,7 +12139,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 ` @@ -11959,6 +12181,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, @@ -11992,7 +12216,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 @@ -12023,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, @@ -12044,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 ` @@ -12095,6 +12321,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, @@ -12128,7 +12356,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 @@ -12159,6 +12387,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, @@ -12180,7 +12410,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 ` @@ -12233,6 +12463,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, @@ -12253,6 +12485,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 @@ -12264,7 +12530,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 @@ -12295,6 +12561,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, @@ -12316,7 +12584,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 ` @@ -12356,6 +12624,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, @@ -12384,7 +12654,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 @@ -12415,6 +12685,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, @@ -12436,7 +12708,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 ` @@ -12483,6 +12755,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, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index a62ce0643891d..3ef81a70975e5 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, @@ -862,6 +868,56 @@ SELECT RETURNING *; +-- name: InsertChatAccountingMessage :one +-- 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, + 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 @@ -913,6 +969,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 +1037,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 +1103,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 +1169,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 +1235,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 +1300,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 +1365,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 +1408,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 +1467,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 +1685,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 +1755,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 +1825,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 +2102,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 +2164,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 +2845,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 +2918,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 +2983,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/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index bc588ccd436c5..9748316516f06 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`. 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. 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 d2db52f031821..6a18fa73b1661 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2887,6 +2887,65 @@ func fantasyUsageToChatMessageUsage(usage fantasy.Usage) codersdk.ChatMessageUsa return chatUsage } +// 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, + 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, + ) + + // 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{ + 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 +2960,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 +2968,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 { @@ -4669,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) @@ -4892,6 +4897,263 @@ 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 + // 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 tags hidden accounting rows; ordinary turn spend stays 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 + } + // 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) + }) +} + +// 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 + } + + // 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 + } + + 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 generation failure. + 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 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) { + //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, chatSummaryWriteTimeout) + 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 background-summary spend via +// recordHiddenUsageMessageTx; 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 3c15609a3c4e0..2f6fc4ad95507 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,67 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t } } +// 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() + + 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 dc31651ac2419..437be408be6ee 100644 --- a/coderd/x/chatd/chatstate/trigger_test.go +++ b/coderd/x/chatd/chatstate/trigger_test.go @@ -261,6 +261,61 @@ 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) 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) + 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. + 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. + _, 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 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') + `, 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 diff --git a/coderd/x/chatd/generation_model_override.go b/coderd/x/chatd/generation_model_override.go new file mode 100644 index 0000000000000..a3ff1ac763764 --- /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 88642d67de488..9de9bc087671b 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" @@ -872,6 +873,214 @@ 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 = 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 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 { + 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 + if noObjErr, ok := errors.AsType[*fantasy.NoObjectGeneratedError](err); ok { + 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 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 i, r := range runes { + if r != '.' && r != '!' && r != '?' { + continue + } + if i == len(runes)-1 || unicode.IsSpace(runes[i+1]) { + 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/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go new file mode 100644 index 0000000000000..ac82cc3693593 --- /dev/null +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -0,0 +1,239 @@ +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)), + // User-only message ("user" visibility) is also included. + summaryTextMessage(t, 8, database.ChatMessageRoleUser, database.ChatMessageVisibilityUser, "and please keep it simple", false, base.Add(3*time.Minute+30*time.Second)), + // Assistant tool-call only message (no text) is skipped. + summaryTestMessage(t, 5, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, + []codersdk.ChatMessagePart{codersdk.ChatMessageToolCall("call-1", "bash", []byte(`{"cmd":"go test"}`))}, + false, base.Add(4*time.Minute)), + // Tool result is skipped. + summaryTextMessage(t, 6, database.ChatMessageRoleTool, database.ChatMessageVisibilityBoth, "tests passed", false, base.Add(5*time.Minute)), + // Assistant final text is kept. + summaryTextMessage(t, 7, database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, "fixed the bug and added a test", false, base.Add(6*time.Minute)), + } + + 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, "[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") + 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("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 + 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.")) +} + +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.", + )) +} diff --git a/coderd/x/chatd/title_override.go b/coderd/x/chatd/title_override.go index 9840a3b471cc8..27d4fcad4434e 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 eadeec97efdd7..818096820459e 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -107,26 +107,29 @@ 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, 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"` + 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"` @@ -1763,13 +1766,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 27556bca61df6..c011ff93b6c58 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_heartbeat_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_heartbeat_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 2f215c50ce10c..a80ec1bc2f620 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, 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 | | | @@ -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 4e4a49745c95d..1c4f6d40ef87b 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, 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 | | | @@ -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.ClusterConfig diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index e197a7782b9f6..63b8e3b8ff0b8 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 52cd88f8d4bf7..c3061a0c590e4 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,70 @@ 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("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 bd4e2ae0994f3..34b12adaa7a2a 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( @@ -456,10 +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; + // 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 @@ -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 cd7ca431860b0..1c6b538949ab3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1566,6 +1566,11 @@ export interface Chat { readonly plan_mode?: ChatPlanMode; readonly last_error?: ChatError; readonly last_turn_summary: string | null; + /** + * Summary is the persisted whole-chat summary, generated asynchronously in + * the background. It is nil until the first summary has been produced. + */ + readonly summary: string | null; readonly diff_status?: ChatDiffStatus; readonly created_at: string; readonly updated_at: string; @@ -3243,6 +3248,7 @@ export interface ChatWatchEvent { // From codersdk/chats.go export type ChatWatchEventKind = | "action_required" + | "chat_summary_change" | "context_dirty" | "created" | "deleted" @@ -3253,6 +3259,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 ac6a4c6c070ca..e57cfa84e0c7e 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/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 02c3826c14626..9e69bc8b1962b 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 3bf3b96c370b3..763620338d2a2 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 2c634ecbb1aaa..623b962fcb569 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,