From 297345b9e0347c5e9e91a8a37d503fba5480c15c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:07:09 +0000 Subject: [PATCH 1/6] feat: add manual chat compaction via /compact --- coderd/apidoc/docs.go | 36 +++ coderd/apidoc/swagger.json | 32 +++ coderd/coderd.go | 1 + coderd/database/dump.sql | 6 +- ...0549_chat_compaction_requested_at.down.sql | 53 ++++ ...000549_chat_compaction_requested_at.up.sql | 61 +++++ coderd/database/modelqueries.go | 4 +- coderd/database/models.go | 3 + coderd/database/querier.go | 7 +- coderd/database/queries.sql.go | 192 ++++++++------ coderd/database/queries/chats.sql | 59 +++-- coderd/exp_chats.go | 77 ++++++ coderd/exp_chats_test.go | 189 ++++++++++++++ coderd/x/chatd/ARCHITECTURE.md | 25 +- coderd/x/chatd/attempt.go | 2 + coderd/x/chatd/chatd.go | 71 ++++++ coderd/x/chatd/chatd_test.go | 139 +++++++++++ coderd/x/chatd/chatloop/chatloop.go | 8 + coderd/x/chatd/chatloop/compaction.go | 38 ++- .../chatloop/compaction_internal_test.go | 107 ++++++++ .../chatstate/request_compaction_test.go | 217 ++++++++++++++++ coderd/x/chatd/chatstate/transition.go | 9 +- coderd/x/chatd/chatstate/transitions.go | 77 +++++- .../chatstate/transitions_matrix_test.go | 39 +++ coderd/x/chatd/generation.go | 42 +++- coderd/x/chatd/message_conversion.go | 12 +- coderd/x/chatd/message_conversion_test.go | 129 ++++++++++ codersdk/chats.go | 19 ++ docs/admin/security/audit-logs.md | 70 +++--- docs/ai-coder/agents/architecture.md | 4 + docs/reference/api/chats.md | 234 ++++++++++++++++++ enterprise/audit/table.go | 1 + site/src/api/api.ts | 12 + site/src/api/queries/chats.ts | 13 + .../AgentsPage/AgentChatPage.stories.tsx | 135 ++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 70 +++++- .../AgentsPage/components/AgentChatInput.tsx | 6 + .../ChatElements/tools/ChatSummarizedTool.tsx | 14 +- .../ChatElements/tools/Tool.stories.tsx | 53 ++++ .../components/ChatElements/tools/Tool.tsx | 8 + .../ChatMessageInput.stories.tsx | 107 ++++++++ .../ChatMessageInput/ChatMessageInput.tsx | 43 +++- .../SkillsTriggerMenu.stories.tsx | 47 ++++ .../ChatMessageInput/SkillsTriggerMenu.tsx | 31 ++- .../AgentsPage/components/ChatPageContent.tsx | 7 + .../pages/AgentsPage/utils/slashCommands.ts | 28 +++ 46 files changed, 2380 insertions(+), 157 deletions(-) create mode 100644 coderd/database/migrations/000549_chat_compaction_requested_at.down.sql create mode 100644 coderd/database/migrations/000549_chat_compaction_requested_at.up.sql create mode 100644 coderd/x/chatd/chatstate/request_compaction_test.go create mode 100644 site/src/pages/AgentsPage/utils/slashCommands.ts diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d5a35e6601a..5222ebf885e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -505,6 +505,42 @@ const docTemplate = `{ } } }, + "/api/experimental/chats/{chat}/compact": { + "post": { + "description": "Experimental: this endpoint is subject to change.\nRequests a manual context compaction on an idle chat. The\ncompaction runs asynchronously through the chat worker and\nbypasses the automatic usage threshold.", + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Compact chat", + "operationId": "compact-chat", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats/{chat}/context": { "put": { "description": "Experimental: this endpoint is subject to change.", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c1d1316b513..7bedd801cfd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -442,6 +442,38 @@ } } }, + "/api/experimental/chats/{chat}/compact": { + "post": { + "description": "Experimental: this endpoint is subject to change.\nRequests a manual context compaction on an idle chat. The\ncompaction runs asynchronously through the chat worker and\nbypasses the automatic usage threshold.", + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Compact chat", + "operationId": "compact-chat", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Chat" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats/{chat}/context": { "put": { "description": "Experimental: this endpoint is subject to change.", diff --git a/coderd/coderd.go b/coderd/coderd.go index bcc95f182e1..3b2f204bd6d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1431,6 +1431,7 @@ func New(options *Options) *API { r.Get("/git", api.watchChatGit) }) r.Post("/interrupt", api.interruptChat) + r.Post("/compact", api.compactChat) r.Post("/reconcile-invalid", api.reconcileInvalidChatState) r.Post("/tool-results", api.postChatToolResults) r.Post("/title/regenerate", api.regenerateChatTitle) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 3e9ca8adb6b..609f924bcbc 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2096,6 +2096,7 @@ CREATE TABLE chats ( context_dirty_resources jsonb, context_error text DEFAULT ''::text NOT NULL, last_reasoning_effort chat_reasoning_effort, + compaction_requested_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))), @@ -2119,6 +2120,8 @@ COMMENT ON COLUMN chats.context_error IS 'Snapshot-level error copied from the p COMMENT ON COLUMN chats.last_reasoning_effort IS 'Stores the most recent message effort once per-turn selection is wired.'; +COMMENT ON COLUMN chats.compaction_requested_at IS 'Set when the chat owner manually requests a context compaction. One-shot signal: consumed by the compaction commit and cleared whenever the chat leaves running.'; + CREATE TABLE users ( id uuid NOT NULL, email text NOT NULL, @@ -2214,7 +2217,8 @@ CREATE VIEW chats_expanded AS c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, - c.context_error + c.context_error, + c.compaction_requested_at 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/000549_chat_compaction_requested_at.down.sql b/coderd/database/migrations/000549_chat_compaction_requested_at.down.sql new file mode 100644 index 00000000000..02267aae60a --- /dev/null +++ b/coderd/database/migrations/000549_chat_compaction_requested_at.down.sql @@ -0,0 +1,53 @@ +DROP VIEW IF EXISTS chats_expanded; + +ALTER TABLE chats + DROP COLUMN compaction_requested_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.last_reasoning_effort, + 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/000549_chat_compaction_requested_at.up.sql b/coderd/database/migrations/000549_chat_compaction_requested_at.up.sql new file mode 100644 index 00000000000..5daa8970cd2 --- /dev/null +++ b/coderd/database/migrations/000549_chat_compaction_requested_at.up.sql @@ -0,0 +1,61 @@ +-- One-shot manual compaction trigger. Set by the RequestCompaction +-- transition when the owner requests a context compaction; consumed by +-- the worker's compaction commit and cleared by every turn-terminal +-- transition so a stale request can never replay on a later turn. +ALTER TABLE chats + ADD COLUMN compaction_requested_at timestamptz; + +COMMENT ON COLUMN chats.compaction_requested_at IS 'Set when the chat owner manually requests a context compaction. One-shot signal: consumed by the compaction commit and cleared whenever the chat leaves running.'; + +-- Refresh chats_expanded to include the new chat column. The gentest +-- TestViewSubsetChat requires every chats column to appear in the view. +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.last_reasoning_effort, + 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, + c.compaction_requested_at + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index ea3213d8ec1..ed1a2e66a04 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -842,6 +842,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, &i.Chat.ContextDirtySince, &i.Chat.ContextDirtyResources, &i.Chat.ContextError, + &i.Chat.CompactionRequestedAt, &i.HasUnread); err != nil { return nil, err } @@ -920,7 +921,8 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID &i.ContextAggregateHash, &i.ContextDirtySince, &i.ContextDirtyResources, - &i.ContextError); err != nil { + &i.ContextError, + &i.CompactionRequestedAt); err != nil { return nil, err } items = append(items, i) diff --git a/coderd/database/models.go b/coderd/database/models.go index 95c091f722b..7a96121b021 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4986,6 +4986,7 @@ type Chat 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"` + CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` } // Per-chat pinned copy of the agent context resources a chat is hydrated against. Copied from workspace_agent_context_resources at chat hydration and context refresh; survives agent replacement and workspace rebuilds. @@ -5207,6 +5208,8 @@ type ChatTable struct { ContextError string `db:"context_error" json:"context_error"` // Stores the most recent message effort once per-turn selection is wired. LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` + // Set when the chat owner manually requests a context compaction. One-shot signal: consumed by the compaction commit and cleared whenever the chat leaves running. + CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` } type ChatUsageLimitConfig struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index bbec10b230c..5c555cdd86f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1390,9 +1390,10 @@ type sqlcQuerier interface { // the injectable quartz.Clock used by FinalizeStale sweeps. UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error) // Atomically updates the execution-state-managed fields on a chat: - // status, archived, last_error, ownership identifiers, and the - // requires-action deadline. Callers compose this with transition - // mutations inside a single ChatMachine.Update transaction. + // status, archived, last_error, ownership identifiers, the + // requires-action deadline, and the manual compaction request marker. + // Callers compose this with transition mutations inside a single + // ChatMachine.Update transaction. UpdateChatExecutionState(ctx context.Context, arg UpdateChatExecutionStateParams) (Chat, error) // Bumps the heartbeat timestamp for the given set of chat IDs, // provided they are still running and owned by the specified diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 63dc190f430..30bd0f24091 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5968,7 +5968,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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -6015,13 +6015,14 @@ chats_expanded AS ( updated_chats.context_aggregate_hash, updated_chats.context_dirty_since, updated_chats.context_dirty_resources, - updated_chats.context_error + updated_chats.context_error, + updated_chats.compaction_requested_at FROM updated_chats 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -6080,6 +6081,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -6130,10 +6132,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, c.last_reasoning_effort + 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.last_reasoning_effort, c.compaction_requested_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.last_reasoning_effort, + 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.last_reasoning_effort, a.compaction_requested_at, -- Children inherit their root's activity so last_activity_at is never null. COALESCE( t.last_activity_at, @@ -6193,6 +6195,7 @@ type AutoArchiveInactiveChatsRow struct { ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` ContextError string `db:"context_error" json:"context_error"` LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` + CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } @@ -6255,6 +6258,7 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi &i.ContextDirtyResources, &i.ContextError, &i.LastReasoningEffort, + &i.CompactionRequestedAt, &i.LastActivityAt, ); err != nil { return nil, err @@ -6561,7 +6565,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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded WHERE agent_id = $1::uuid AND archived = false @@ -6625,6 +6629,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -6641,7 +6646,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.last_reasoning_effort, 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.last_reasoning_effort, 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.compaction_requested_at, COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at FROM chats_expanded LEFT JOIN LATERAL ( @@ -6716,6 +6721,7 @@ type GetAutoArchiveInactiveChatCandidatesRow 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"` + CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } @@ -6776,6 +6782,7 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, &i.LastActivityAt, ); err != nil { return nil, err @@ -6814,7 +6821,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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded WHERE id = $1::uuid ` @@ -6867,13 +6874,14 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } 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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at FROM chats WHERE id = $1::uuid FOR SHARE @@ -6923,13 +6931,14 @@ chats_expanded AS ( shared_chat.context_aggregate_hash, shared_chat.context_dirty_since, shared_chat.context_dirty_resources, - shared_chat.context_error + shared_chat.context_error, + shared_chat.compaction_requested_at FROM shared_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -6981,13 +6990,14 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } 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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at FROM chats WHERE id = $1::uuid FOR UPDATE @@ -7037,13 +7047,14 @@ chats_expanded AS ( locked_chat.context_aggregate_hash, locked_chat.context_dirty_since, locked_chat.context_dirty_resources, - locked_chat.context_error + locked_chat.context_error, + locked_chat.compaction_requested_at FROM locked_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -7095,6 +7106,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -8553,7 +8565,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.last_reasoning_effort, 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.last_reasoning_effort, 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.compaction_requested_at, chat_heartbeats.heartbeat_at AS current_heartbeat_at, NOT EXISTS ( SELECT 1 @@ -8634,6 +8646,7 @@ type GetChatWorkerAcquisitionCandidatesRow 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"` + CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` CurrentHeartbeatAt sql.NullTime `db:"current_heartbeat_at" json:"current_heartbeat_at"` HeartbeatStale bool `db:"heartbeat_stale" json:"heartbeat_stale"` } @@ -8703,6 +8716,7 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, &i.CurrentHeartbeatAt, &i.HeartbeatStale, ); err != nil { @@ -8729,7 +8743,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.last_reasoning_effort, 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.last_reasoning_effort, 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.compaction_requested_at, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -9027,6 +9041,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha &i.Chat.ContextDirtySince, &i.Chat.ContextDirtyResources, &i.Chat.ContextError, + &i.Chat.CompactionRequestedAt, &i.HasUnread, ); err != nil { return nil, err @@ -9044,7 +9059,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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded WHERE @@ -9111,6 +9126,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -9126,7 +9142,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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded WHERE id = ANY($1::uuid[]) ORDER BY id ASC @@ -9186,6 +9202,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -9201,7 +9218,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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -9262,6 +9279,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -9346,7 +9364,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.last_reasoning_effort, 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.last_reasoning_effort, 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.compaction_requested_at, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -9435,6 +9453,7 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC &i.Chat.ContextDirtySince, &i.Chat.ContextDirtyResources, &i.Chat.ContextError, + &i.Chat.CompactionRequestedAt, &i.HasUnread, ); err != nil { return nil, err @@ -9518,7 +9537,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh const getStaleChats = `-- name: GetStaleChats :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded WHERE @@ -9595,6 +9614,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -9823,7 +9843,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -9870,13 +9890,14 @@ chats_expanded AS ( inserted_chat.context_aggregate_hash, inserted_chat.context_dirty_since, inserted_chat.context_dirty_resources, - inserted_chat.context_error + inserted_chat.context_error, + inserted_chat.compaction_requested_at FROM inserted_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -9964,6 +9985,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -10451,7 +10473,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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -10498,12 +10520,13 @@ chats_expanded AS ( bumped_chat.context_aggregate_hash, bumped_chat.context_dirty_since, bumped_chat.context_dirty_resources, - bumped_chat.context_error + bumped_chat.context_error, + bumped_chat.compaction_requested_at FROM bumped_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -10559,6 +10582,7 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -10889,7 +10913,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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -10936,13 +10960,14 @@ chats_expanded AS ( updated_chats.context_aggregate_hash, updated_chats.context_dirty_since, updated_chats.context_dirty_resources, - updated_chats.context_error + updated_chats.context_error, + updated_chats.compaction_requested_at FROM updated_chats 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -11005,6 +11030,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ); err != nil { return nil, err } @@ -11107,7 +11133,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -11154,13 +11180,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -11218,6 +11245,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -11231,7 +11259,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -11278,13 +11306,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -11341,6 +11370,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -11355,10 +11385,11 @@ WITH updated_chat AS ( runner_id = $4::uuid, last_error = $5::jsonb, requires_action_deadline_at = $6::timestamptz, + compaction_requested_at = $7::timestamptz, 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, last_reasoning_effort + WHERE id = $8::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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -11405,12 +11436,13 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -11421,13 +11453,15 @@ type UpdateChatExecutionStateParams struct { RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` + CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"` ID uuid.UUID `db:"id" json:"id"` } // Atomically updates the execution-state-managed fields on a chat: -// status, archived, last_error, ownership identifiers, and the -// requires-action deadline. Callers compose this with transition -// mutations inside a single ChatMachine.Update transaction. +// status, archived, last_error, ownership identifiers, the +// requires-action deadline, and the manual compaction request marker. +// Callers compose this with transition mutations inside a single +// ChatMachine.Update transaction. func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateChatExecutionStateParams) (Chat, error) { row := q.db.QueryRowContext(ctx, updateChatExecutionState, arg.Status, @@ -11436,6 +11470,7 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha arg.RunnerID, arg.LastError, arg.RequiresActionDeadlineAt, + arg.CompactionRequestedAt, arg.ID, ) var i Chat @@ -11484,6 +11519,7 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -11542,7 +11578,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -11589,13 +11625,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -11652,6 +11689,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -11665,7 +11703,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -11712,13 +11750,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -11775,6 +11814,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -11838,7 +11878,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -11885,13 +11925,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -11948,6 +11989,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -12032,7 +12074,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -12079,13 +12121,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -12142,6 +12185,7 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -12153,7 +12197,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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -12200,12 +12244,13 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -12264,6 +12309,7 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -12281,7 +12327,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -12328,13 +12374,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -12402,6 +12449,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } @@ -12417,7 +12465,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, last_reasoning_effort +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, last_reasoning_effort, compaction_requested_at ), chats_expanded AS ( SELECT @@ -12464,13 +12512,14 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -12527,13 +12576,14 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } const updateChatWorkspaceBinding = `-- name: UpdateChatWorkspaceBinding :one WITH current_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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at FROM chats WHERE id = $1::uuid ), @@ -12552,13 +12602,13 @@ changed_chat AS ( updated_at = NOW() WHERE id = $1::uuid AND (SELECT changed FROM binding_changed) - 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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at ), result_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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at FROM changed_chat UNION ALL - 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, last_reasoning_effort + 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, last_reasoning_effort, compaction_requested_at FROM current_chat WHERE NOT (SELECT changed FROM binding_changed) ), @@ -12607,13 +12657,14 @@ chats_expanded AS ( result_chat.context_aggregate_hash, result_chat.context_dirty_since, result_chat.context_dirty_resources, - result_chat.context_error + result_chat.context_error, + result_chat.compaction_requested_at FROM result_chat LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id) JOIN visible_users owner ON owner.id = result_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, last_reasoning_effort, archived, last_error, mode, mcp_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, last_reasoning_effort, archived, last_error, mode, mcp_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, compaction_requested_at FROM chats_expanded ` @@ -12677,6 +12728,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.CompactionRequestedAt, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 62f243f645d..d1a796c54c8 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -50,7 +50,8 @@ chats_expanded AS ( updated_chats.context_aggregate_hash, updated_chats.context_dirty_since, updated_chats.context_dirty_resources, - updated_chats.context_error + updated_chats.context_error, + updated_chats.compaction_requested_at FROM updated_chats LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) @@ -117,7 +118,8 @@ chats_expanded AS ( updated_chats.context_aggregate_hash, updated_chats.context_dirty_since, updated_chats.context_dirty_resources, - updated_chats.context_error + updated_chats.context_error, + updated_chats.compaction_requested_at FROM updated_chats LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) @@ -849,7 +851,8 @@ chats_expanded AS ( inserted_chat.context_aggregate_hash, inserted_chat.context_dirty_since, inserted_chat.context_dirty_resources, - inserted_chat.context_error + inserted_chat.context_error, + inserted_chat.compaction_requested_at FROM inserted_chat LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id) @@ -990,7 +993,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1057,7 +1061,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1122,7 +1127,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1187,7 +1193,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1252,7 +1259,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1337,7 +1345,8 @@ chats_expanded AS ( result_chat.context_aggregate_hash, result_chat.context_dirty_since, result_chat.context_dirty_resources, - result_chat.context_error + result_chat.context_error, + result_chat.compaction_requested_at FROM result_chat LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id) @@ -1401,7 +1410,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1483,7 +1493,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1690,7 +1701,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) @@ -1966,7 +1978,8 @@ chats_expanded AS ( locked_chat.context_aggregate_hash, locked_chat.context_dirty_since, locked_chat.context_dirty_resources, - locked_chat.context_error + locked_chat.context_error, + locked_chat.compaction_requested_at FROM locked_chat LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id) @@ -2027,7 +2040,8 @@ chats_expanded AS ( shared_chat.context_aggregate_hash, shared_chat.context_dirty_since, shared_chat.context_dirty_resources, - shared_chat.context_error + shared_chat.context_error, + shared_chat.compaction_requested_at FROM shared_chat LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id) @@ -2701,7 +2715,8 @@ chats_expanded AS ( bumped_chat.context_aggregate_hash, bumped_chat.context_dirty_since, bumped_chat.context_dirty_resources, - bumped_chat.context_error + bumped_chat.context_error, + bumped_chat.compaction_requested_at FROM bumped_chat 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 @@ -2711,9 +2726,10 @@ FROM chats_expanded; -- name: UpdateChatExecutionState :one -- Atomically updates the execution-state-managed fields on a chat: --- status, archived, last_error, ownership identifiers, and the --- requires-action deadline. Callers compose this with transition --- mutations inside a single ChatMachine.Update transaction. +-- status, archived, last_error, ownership identifiers, the +-- requires-action deadline, and the manual compaction request marker. +-- Callers compose this with transition mutations inside a single +-- ChatMachine.Update transaction. WITH updated_chat AS ( UPDATE chats SET @@ -2723,6 +2739,7 @@ WITH updated_chat AS ( runner_id = sqlc.narg('runner_id')::uuid, last_error = sqlc.narg('last_error')::jsonb, requires_action_deadline_at = sqlc.narg('requires_action_deadline_at')::timestamptz, + compaction_requested_at = sqlc.narg('compaction_requested_at')::timestamptz, pin_order = CASE WHEN @archived::boolean THEN 0 ELSE pin_order END, updated_at = NOW() WHERE id = @id::uuid @@ -2773,7 +2790,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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 @@ -2837,7 +2855,8 @@ chats_expanded AS ( updated_chat.context_aggregate_hash, updated_chat.context_dirty_since, updated_chat.context_dirty_resources, - updated_chat.context_error + updated_chat.context_error, + updated_chat.compaction_requested_at FROM updated_chat 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 diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 6ae28da6a91..74a0b67533f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3974,6 +3974,83 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, nil, nil)) } +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Compact chat +// @ID compact-chat +// @Security CoderSessionToken +// @Tags Chats +// @Param chat path string true "Chat ID" format(uuid) +// @Produce json +// @Success 200 {object} codersdk.Chat +// @Router /api/experimental/chats/{chat}/compact [post] +// @Description Experimental: this endpoint is subject to change. +// @Description Requests a manual context compaction on an idle chat. The +// @Description compaction runs asynchronously through the chat worker and +// @Description bypasses the automatic usage threshold. +func (api *API) compactChat(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + chat := httpmw.ChatParam(r) + chatID := chat.ID + logger := api.Logger.Named("chat_compact").With(slog.F("chat_id", chatID)) + + if !api.requireChatDaemon(ctx, rw) { + return + } + + // Compaction triggers LLM inference, requiring update permission + // on the org-scoped chat resource. + if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) { + httpapi.ResourceNotFound(rw) + return + } + + // Only the chat owner may trigger compaction. Org admins pass the + // RBAC check above (org-level ActionUpdate), but compaction runs + // inference with the owner's delegated credentials. + if apiKey.UserID != chat.OwnerID { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Only the chat owner may compact the chat.", + }) + return + } + + updated, err := api.chatDaemon.CompactChat(ctx, chat) + if err != nil { + if maybeWriteLimitErr(ctx, rw, err) { + return + } + if writeCommonChatMutationError(ctx, rw, err, "Cannot compact an archived chat.") { + return + } + switch { + case errors.Is(err, chatd.ErrNothingToCompact): + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Nothing to compact.", + Detail: "The chat has no conversation to summarize after the latest compaction.", + }) + case errors.Is(err, chatstate.ErrTransitionNotAllowed): + // Covers every non-waiting state: running, interrupting, + // requires-action, and error. "Busy" would misdescribe an + // errored chat, so keep the message state-neutral. + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "Cannot compact the chat in its current state.", + Detail: "Compaction is only available while the chat is idle.", + }) + default: + logger.Error(ctx, "failed to compact chat", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to compact chat.", + Detail: err.Error(), + }) + } + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil)) +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // // @Summary Reconcile invalid chat state diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 7a16c26597f..09d934b341a 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -9394,6 +9394,184 @@ func TestInterruptChat(t *testing.T) { }) } +func TestCompactChat(t *testing.T) { + t.Parallel() + + // seedCompactableChat inserts an idle chat with one user and one + // assistant message so a manual compaction has something to + // summarize. + seedCompactableChat := func(t *testing.T, db database.Store, orgID, ownerID, modelConfigID uuid.UUID) database.Chat { + t.Helper() + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: orgID, + OwnerID: ownerID, + LastModelConfigID: modelConfigID, + Title: "compact route test", + }) + userContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("question"), + }) + require.NoError(t, err) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: ownerID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: database.ChatMessageRoleUser, + Content: userContent, + }) + assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("answer"), + }) + require.NoError(t, err) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + Content: assistantContent, + }) + return chat + } + + t.Run("RequestsCompaction", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) + + // Assert on the response snapshot only: the chat is runnable + // after the transition, so a worker may already be mutating + // the persisted row. + compacted, err := client.CompactChat(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chat.ID, compacted.ID) + require.Equal(t, codersdk.ChatStatusRunning, compacted.Status) + }) + + t.Run("NothingToCompact", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + // Idle chat with only a user message: no assistant turn to + // summarize. + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "compact empty test", + }) + userContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("question"), + }) + require.NoError(t, err) + _ = dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + CreatedBy: uuid.NullUUID{UUID: user.UserID, Valid: true}, + ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true}, + Role: database.ChatMessageRoleUser, + Content: userContent, + }) + + _, err = client.CompactChat(ctx, chat.ID) + sdkErr := requireSDKError(t, err, http.StatusConflict) + require.Contains(t, sdkErr.Message, "Nothing to compact") + + persisted, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusWaiting, persisted.Status) + require.False(t, persisted.CompactionRequestedAt.Valid) + }) + + t.Run("Busy", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) + + _, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) + require.NoError(t, err) + + _, err = client.CompactChat(ctx, chat.ID) + sdkErr := requireSDKError(t, err, http.StatusConflict) + require.Contains(t, sdkErr.Message, "Cannot compact the chat in its current state") + }) + + t.Run("Archived", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) + + _, err := db.ArchiveChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + + _, err = client.CompactChat(ctx, chat.ID) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "archived") + }) + + t.Run("ChatNotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + _, err := client.CompactChat(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) + + // Even the owner needs RBAC update permission on the chat. + t.Run("UpdateDenied", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + clientRaw, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Authorizer: &coderdtest.FakeAuthorizer{ + ConditionalReturn: func(_ context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error { + // dbgen seeds rows with a synthetic "owner" subject; + // message inserts need chat update, so let them pass. + if subject.ID == "owner" { + return nil + } + if action == policy.ActionUpdate && object.Type == rbac.ResourceChat.Type { + return xerrors.New("denied") + } + return nil + }, + }, + DeploymentValues: coderdtest.DeploymentValues(t), + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + db := api.Database + client := codersdk.NewExperimentalClient(clientRaw) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + chat := seedCompactableChat(t, db, user.OrganizationID, user.UserID, modelConfig.ID) + + _, err := client.CompactChat(ctx, chat.ID) + requireSDKError(t, err, http.StatusNotFound) + }) +} + func TestRegenerateChatTitle(t *testing.T) { t.Parallel() @@ -16630,6 +16808,17 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) { require.Contains(t, sdkErr.Message, "Only the chat owner") }) + t.Run("CompactChat", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + _, adminClient, chat, _ := setupOrgAdminAndOwnerChat(t) + + _, err := adminClient.CompactChat(ctx, chat.ID) + sdkErr := requireSDKError(t, err, http.StatusForbidden) + require.Contains(t, sdkErr.Message, "Only the chat owner") + }) + t.Run("PatchChatMessage", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index d07d88b03e9..fa64cd8bd44 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -116,6 +116,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t - `PromoteQueuedMessage(qid)` makes a queued message the next message to process. It reorders the queue, interrupts active work, cancels pending dynamic-tool action, or promotes into history immediately as required by the input state. - `Interrupt(reason)` requests cancellation of an active generation or closes pending dynamic-tool action. It preserves queued backlog. - `CompleteRequiresAction(results)` inserts submitted tool-result messages, clears `requires_action_deadline_at`, and lands in `running`. It preserves queued messages. +- `RequestCompaction` records a manual compaction request on an idle chat by setting `compaction_requested_at` and landing in `running` without inserting any message. The chat worker picks the chat up like any other running chat and consumes the request. See [Manual compaction](#manual-compaction). ### Transitions used by the chat worker @@ -147,6 +148,7 @@ stateDiagram-v2 W --> R0: SendMessage W --> R0: EditMessage + W --> R0: RequestCompaction W --> XW: SetArchived(true) E0 --> R0: SendMessage @@ -541,6 +543,14 @@ This endpoint uses `CompleteRequiresAction(results)`: No other input states are supported. +### `POST /api/experimental/chats/{chat}/compact` + +This endpoint uses `RequestCompaction`: + +- `W -> RequestCompaction -> R0` + +No other input states are supported: busy chats get a conflict error, and archived chats are rejected. The endpoint is owner-only because the compaction runs LLM inference with the owner's delegated credentials. Inside the same transaction, after the transition succeeds, the endpoint verifies there is at least one uncompressed assistant message after the latest compaction boundary and rolls back with a "nothing to compact" conflict otherwise, so no LLM call is ever started for an empty or already-compacted chat. See [Manual compaction](#manual-compaction) for how the worker consumes the request. + ## Pubsub The chat worker and the stream loop need real-time notifications when the chat state changes to ensure they are responsive. To achieve this, we use pubsub. @@ -825,7 +835,7 @@ Parallel tool call results must be inserted in bulk after all parallel tool call The generation goroutine supports: -- chat compaction +- chat compaction (automatic and manual, see [Manual compaction](#manual-compaction)) - MCP tools - file links - workspace binding @@ -881,6 +891,19 @@ When the manager cleans up a runner, the runner must cancel all goroutines it ha The worker periodically archives old, unused chats. +## Manual compaction + +Compaction reduces the LLM prompt size by summarizing older history into a compressed boundary. It normally runs automatically: while preparing a generation, the worker compares the latest known token usage against the model's compaction threshold, and when the threshold is exceeded it makes a non-streaming LLM call to produce a summary and commits it as a compressed message triplet (a hidden model-only summary boundary, a visible `chat_summarized` tool call, and its tool result). Prompt queries prune history at the newest boundary. + +Users can also request a compaction on demand via `POST /api/experimental/chats/{chat}/compact` (surfaced in the web UI as the `/compact` slash command). Manual compaction is a durable one-shot request executed through the normal worker loop rather than synchronously in the HTTP handler. This reuses the worker's lock fencing, retry accounting, streamed "Summarizing..." progress parts, metrics, and debug runs, and it survives replica crashes. The flow: + +1. The endpoint applies the `RequestCompaction` transition: only allowed from `W`, sets `chats.compaction_requested_at = now()`, lands in `R0` without inserting any message, and publishes a status-change pubsub event to wake workers. A timestamp is used instead of a boolean for debuggability. AI Gateway attribution needs no per-request key: generation preparation resolves the owner's synthetic API key like any other turn. +2. The generation goroutine's decision logic checks `compaction_requested_at` after the unresolved local/dynamic tool guards but before the history-completeness check (an idle chat's history is otherwise complete, which would end the turn). If the marker is set and at least one uncompressed assistant message exists after the latest compaction boundary, it selects a forced compaction; if there is nothing to compact, the marker is ignored and the turn finishes normally, clearing it. +3. A forced compaction bypasses the automatic threshold gates (usage below threshold, unknown context window, and the threshold=100 disable) and stamps `source: "manual"` instead of `source: "automatic"` into the `chat_summarized` tool call arguments, tool result JSON, and streamed parts so clients can render manual compactions distinctly. +4. The compaction `CommitStep` consumes the request by clearing `compaction_requested_at` in the same transaction that commits the summary triplet. The next decision pass finds the history complete and finishes the turn, so the chat returns to `waiting` with no assistant follow-up. + +The `compaction_requested_at` marker is one-shot: transitions that keep an active turn alive (`Acquire`, `Abandon`, `SetArchived`, queueing a message on a busy chat) carry it forward, while every other transition that rewrites the execution state (`FinishTurn`, `FinishError`, `Interrupt`, `EditMessage`, `PromoteQueuedMessage`, `CancelRequiresAction`, `ReconcileInvalidState`, and so on) clears it by construction, so a stale request can never replay on a later turn. + # Stream loop The stream loop powers the `GET /api/experimental/chats/{chat}/stream` endpoint. It is scoped to one chat and one client WebSocket. It's responsible for delivering a stream of chat updates to the client, including: diff --git a/coderd/x/chatd/attempt.go b/coderd/x/chatd/attempt.go index 5283cbe6479..1e1cc6b0e2d 100644 --- a/coderd/x/chatd/attempt.go +++ b/coderd/x/chatd/attempt.go @@ -6,6 +6,7 @@ import ( "charm.land/fantasy" + "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/codersdk" ) @@ -47,6 +48,7 @@ type pendingDynamicToolCall struct { type compactionOutcome struct { SystemSummary string SummaryReport string + Source chatloop.CompactionSource ThresholdPercent int32 UsagePercent float64 ContextTokens int64 diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 175e497a7fc..9e70625262d 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1135,6 +1135,10 @@ var ( // ErrNoDefaultChatModelConfig indicates no default chat model config // is configured, so chatd cannot resolve a model for the request. ErrNoDefaultChatModelConfig = xerrors.New("no default chat model config is configured") + // ErrNothingToCompact indicates a manual compaction request found + // no uncompressed conversation after the latest compaction + // boundary, so running a compaction would produce nothing. + ErrNothingToCompact = xerrors.New("nothing to compact") ) // UsageLimitExceededError indicates the user has exceeded their chat spend @@ -2125,6 +2129,73 @@ func (p *Server) InterruptChat( return refreshed, nil } +// CompactChat records a manual compaction request through the +// chatstate.RequestCompaction transition and wakes workers. The chat +// must be idle (waiting); the worker then generates and commits the +// compaction summary through the normal generation loop, bypassing +// the usage threshold, and the chat returns to waiting with no +// assistant follow-up. +// +// Returns the post-transition chat and an error so callers can map +// state conflicts deliberately: archived chats return ErrChatArchived, +// non-idle chats return a chatstate.ErrTransitionNotAllowed wrapper, +// and chats with no compactable conversation return +// ErrNothingToCompact. +func (p *Server) CompactChat( + ctx context.Context, + chat database.Chat, +) (database.Chat, error) { + if chat.ID == uuid.Nil { + return chat, xerrors.New("chat_id is required") + } + + var refreshed database.Chat + machine := p.newChatMachine(chat.ID) + err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + lockedChat, err := store.GetChatByID(ctx, chat.ID) + if err != nil { + return xerrors.Errorf("load chat: %w", err) + } + if lockedChat.Archived { + return ErrChatArchived + } + // Compaction triggers LLM inference billed to the owner, so + // enforce usage limits like message sends do. + if limitErr := p.checkUsageLimit(ctx, store, lockedChat.OwnerID, uuid.NullUUID{UUID: lockedChat.OrganizationID, Valid: true}); limitErr != nil { + return limitErr + } + // Run the transition first so busy chats surface the state + // conflict rather than a misleading nothing-to-compact. + result, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) + if err != nil { + return err + } + // Reject requests with nothing to compact inside the same + // transaction (rolling back the transition) so no LLM call + // is ever started for an empty or already-compacted chat. + // This also covers a double-/compact. + messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + AfterID: 0, + }) + if err != nil { + return xerrors.Errorf("load chat messages: %w", err) + } + boundary := latestCompactionBoundaryIndex(messages) + if _, ok := firstUncompressedAssistantAfter(messages, boundary); !ok { + return ErrNothingToCompact + } + refreshed = result.Chat + return nil + }) + if err != nil { + return chat, err + } + + p.publishChatPubsubEvent(refreshed, codersdk.ChatWatchEventKindStatusChange, nil) + return refreshed, nil +} + // ReconcileInvalidStateChat recovers a chat stuck in an invalid // execution-state combination by running the // chatstate.ReconcileInvalidState transition. The chat lands in an diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index b3b74e19ca4..7242a1ea69b 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -5660,6 +5660,145 @@ func TestActiveServer_Compaction(t *testing.T) { }) } +func TestActiveServer_ManualCompaction(t *testing.T) { + t.Parallel() + + const compactionSummary = "manual compaction summary" + + t.Run("compacts below threshold and returns to waiting", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + var streamCount atomic.Int32 + var compactionRequests atomic.Int32 + anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + body := anthropicRequestBody(t, *req) + if !req.Stream { + if strings.Contains(body, "You are performing a context compaction") { + compactionRequests.Add(1) + require.Contains(t, body, "hello from the user") + return anthropicCompactionResponse(compactionSummary) + } + return chattest.AnthropicNonStreamingResponse("title") + } + streamCount.Add(1) + // Low usage: far below the 70% threshold so only a + // manual request can trigger compaction. + return chattest.AnthropicStreamingResponse(chattest.AnthropicTextChunksWithCacheUsage(chattest.AnthropicUsage{ + InputTokens: 10, + OutputTokens: 5, + }, "assistant answer")...) + }) + user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) + model = updateChatModelCompressionThreshold(t, db, model, 100, 70) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())) + }) + chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "hello from the user") + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.Equal(t, int32(1), streamCount.Load()) + require.Equal(t, int32(0), compactionRequests.Load()) + preCompactionMessageCount := len(chatMessages(ctx, t, db, chat.ID)) + + compacted, err := server.CompactChat(ctx, chat) + require.NoError(t, err) + require.Equal(t, database.ChatStatusRunning, compacted.Status) + require.True(t, compacted.CompactionRequestedAt.Valid) + + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.False(t, chat.LastError.Valid) + require.False(t, chat.CompactionRequestedAt.Valid, + "compaction commit must consume the request marker") + require.Equal(t, int32(1), compactionRequests.Load(), "one forced compaction call") + require.Equal(t, int32(1), streamCount.Load(), + "manual compaction must not trigger an assistant follow-up") + + messages := chatMessages(ctx, t, db, chat.ID) + promptMessages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + compressed := compressedChatSummarizedMessages(t, append(promptMessages, messages...)) + require.Len(t, compressed.results, 1) + resultPart := singlePartOfType(t, compressed.results[0], codersdk.ChatMessagePartTypeToolResult) + require.Equal(t, "chat_summarized", resultPart.ToolName) + var result map[string]any + require.NoError(t, json.Unmarshal(resultPart.Result, &result)) + require.Equal(t, "manual", result["source"]) + require.Equal(t, compactionSummary, result["summary"]) + // The commit inserts the summary triplet: a hidden model-only + // boundary (visible only through the prompt query) plus the + // user-visible tool call/result pair. + require.Len(t, compressed.summaries, 1, + "prompt history contains the compressed summary boundary") + require.Len(t, compressed.calls, 1) + require.Len(t, messages, preCompactionMessageCount+2, + "user-visible history grows by the summary tool call/result pair") + + // A second /compact with nothing new to summarize is + // rejected before any LLM call. + _, err = server.CompactChat(ctx, chat) + require.ErrorIs(t, err, chatd.ErrNothingToCompact) + require.Equal(t, int32(1), compactionRequests.Load()) + + // The chat still works: a follow-up message continues from + // the compacted summary. + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + ModelConfigID: model.ID, + Content: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("continue after manual compaction"), + }, + }) + require.NoError(t, err) + chat = waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + require.False(t, chat.LastError.Valid) + require.Equal(t, int32(2), streamCount.Load()) + }) + + t.Run("busy chat rejects manual compaction", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + db, ps := dbtestutil.NewDB(t) + release := make(chan struct{}) + releaseOnce := sync.OnceFunc(func() { close(release) }) + streamStarted := make(chan struct{}, 1) + anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { + if !req.Stream { + return chattest.AnthropicNonStreamingResponse("title") + } + select { + case streamStarted <- struct{}{}: + default: + } + // Hold the generation open so the chat stays running. + <-release + return chattest.AnthropicStreamingResponse(chattest.AnthropicTextChunks("done")...) + }) + user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath())) + }) + // Registered after newActiveTestServer so this cleanup runs + // before server shutdown: a failed assertion must not leave + // shutdown waiting on the blocked stream. + t.Cleanup(releaseOnce) + chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "stay busy") + // Wait for the generation to reach the blocked LLM call: the + // chat is then running with an owning worker. + testutil.TryReceive(ctx, t, streamStarted) + + _, err := server.CompactChat(ctx, chat) + require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed) + + releaseOnce() + waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) + }) +} + type compressedCompactionMessages struct { summaries []database.ChatMessage calls []database.ChatMessage diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index 17d8f42e855..ff659f1021a 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -278,6 +278,14 @@ type GenerateCompactionOptions struct { StepUsage fantasy.Usage StepMetadata fantasy.ProviderMetadata + // Force skips the threshold gate (including the threshold=100 + // disable and the zero-usage early return). Set for manual, + // user-requested compactions. + Force bool + // Source labels what triggered the compaction. Defaults to + // CompactionSourceAutomatic when empty. + Source CompactionSource + DebugSvc *chatdebug.Service ChatID uuid.UUID HistoryTipMessageID int64 diff --git a/coderd/x/chatd/chatloop/compaction.go b/coderd/x/chatd/chatloop/compaction.go index 093939f645a..e0bcef5bb9c 100644 --- a/coderd/x/chatd/chatloop/compaction.go +++ b/coderd/x/chatd/chatloop/compaction.go @@ -72,6 +72,17 @@ const ( "the context was compacted. Continue the work described below:" ) +// CompactionSource identifies what triggered a compaction. It is +// recorded in the persisted chat_summarized tool JSON and the +// streamed synthetic parts so clients can render manual compactions +// distinctly. +type CompactionSource string + +const ( + CompactionSourceAutomatic CompactionSource = "automatic" + CompactionSourceManual CompactionSource = "manual" +) + type CompactionOptions struct { ThresholdPercent int32 ContextLimit int64 @@ -89,6 +100,14 @@ type CompactionOptions struct { ModelConfigID uuid.UUID ProviderOptions fantasy.ProviderOptions + // Force skips the threshold gate (including the threshold=100 + // disable and the zero-usage early return). Set for manual, + // user-requested compactions. + Force bool + // Source labels what triggered the compaction. Defaults to + // CompactionSourceAutomatic when empty. + Source CompactionSource + // ToolCallID and ToolName identify the synthetic tool call // used to represent compaction in the message stream. ToolCallID string @@ -105,6 +124,7 @@ type CompactionOptions struct { type CompactionResult struct { SystemSummary string SummaryReport string + Source CompactionSource ThresholdPercent int32 UsagePercent float64 ContextTokens int64 @@ -113,6 +133,8 @@ type CompactionResult struct { // GenerateCompaction generates one context summary and returns it without // persisting. It publishes compaction progress parts when configured. +// Threshold gating (including the threshold=100 disable and the +// zero-usage early return) is skipped when opts.Force is set. func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (CompactionResult, error) { if opts.Model == nil { return CompactionResult{}, xerrors.New("chat model is required") @@ -123,7 +145,7 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co } contextTokens := contextTokensFromUsage(opts.StepUsage) - if contextTokens <= 0 { + if contextTokens <= 0 && !config.Force { return CompactionResult{}, nil } metadataLimit := extractContextLimit(opts.StepMetadata) @@ -137,7 +159,7 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co contextLimit, config.ThresholdPercent, ) - if !compact { + if !compact && !config.Force { return CompactionResult{}, nil } @@ -163,6 +185,7 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co config.SystemSummaryPrefix + "\n\n" + summary, ), SummaryReport: summary, + Source: config.Source, ThresholdPercent: config.ThresholdPercent, UsagePercent: usagePercent, ContextTokens: contextTokens, @@ -171,7 +194,7 @@ func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (Co if config.PublishMessagePart != nil && config.ToolCallID != "" { resultJSON, _ := json.Marshal(map[string]any{ "summary": summary, - "source": "automatic", + "source": config.Source, "threshold_percent": config.ThresholdPercent, "usage_percent": usagePercent, "context_tokens": contextTokens, @@ -198,6 +221,8 @@ func normalizedCompactionGenerateConfig(opts GenerateCompactionOptions) (Compact ResolvedModel: opts.ResolvedModel, ModelConfigID: opts.ModelConfigID, ProviderOptions: opts.ProviderOptions, + Force: opts.Force, + Source: opts.Source, ToolCallID: opts.ToolCallID, ToolName: opts.ToolName, PublishMessagePart: opts.PublishMessagePart, @@ -208,11 +233,16 @@ func normalizedCompactionGenerateConfig(opts GenerateCompactionOptions) (Compact if strings.TrimSpace(config.SystemSummaryPrefix) == "" { config.SystemSummaryPrefix = defaultCompactionSystemSummaryPrefix } + if config.Source == "" { + config.Source = CompactionSourceAutomatic + } if config.ThresholdPercent < minCompactionThresholdPercent || config.ThresholdPercent > maxCompactionThresholdPercent { config.ThresholdPercent = defaultCompactionThresholdPercent } - if config.ThresholdPercent == maxCompactionThresholdPercent { + // threshold=100 disables automatic compaction; a forced run + // still proceeds because the user asked explicitly. + if config.ThresholdPercent == maxCompactionThresholdPercent && !config.Force { return CompactionOptions{}, false } return config, true diff --git a/coderd/x/chatd/chatloop/compaction_internal_test.go b/coderd/x/chatd/chatloop/compaction_internal_test.go index f4f086a6984..422aaa5d98c 100644 --- a/coderd/x/chatd/chatloop/compaction_internal_test.go +++ b/coderd/x/chatd/chatloop/compaction_internal_test.go @@ -263,3 +263,110 @@ func TestGenerateCompactionSummary_UsesCallerContext(t *testing.T) { require.False(t, ok) require.Equal(t, "value", ctxSeen.Value(contextKey("key"))) } + +// TestGenerateCompaction_ForceBypassesThresholdGates verifies the +// manual-compaction contract: Force runs the summary even when usage +// is below threshold, when usage is zero, and when threshold=100 +// disables automatic compaction; without Force those gates return an +// empty result without calling the model. +func TestGenerateCompaction_ForceBypassesThresholdGates(t *testing.T) { + t.Parallel() + + newModel := func(calls *int) *chattest.FakeModel { + return &chattest.FakeModel{ + ProviderName: "fake", + ModelName: "fake-model", + GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) { + *calls++ + return &fantasy.Response{ + Content: []fantasy.Content{ + fantasy.TextContent{Text: "forced summary"}, + }, + }, nil + }, + } + } + messages := []fantasy.Message{textMessage(fantasy.MessageRoleUser, "hello")} + + cases := []struct { + name string + opts GenerateCompactionOptions + }{ + { + name: "below threshold", + opts: GenerateCompactionOptions{ + ThresholdPercent: 70, + ContextLimit: 1000, + StepUsage: fantasy.Usage{InputTokens: 10}, + }, + }, + { + name: "zero usage", + opts: GenerateCompactionOptions{ + ThresholdPercent: 70, + ContextLimit: 1000, + }, + }, + { + name: "threshold disabled", + opts: GenerateCompactionOptions{ + ThresholdPercent: 100, + ContextLimit: 1000, + StepUsage: fantasy.Usage{InputTokens: 10}, + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Without Force the gate returns an empty result and + // never calls the model. + calls := 0 + opts := tc.opts + opts.Model = newModel(&calls) + opts.Messages = messages + result, err := GenerateCompaction(context.Background(), opts) + require.NoError(t, err) + require.Empty(t, result.SummaryReport) + require.Zero(t, calls, "gated run must not call the model") + + // With Force the summary is generated and labeled manual. + opts.Force = true + opts.Source = CompactionSourceManual + result, err = GenerateCompaction(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, "forced summary", result.SummaryReport) + require.Equal(t, CompactionSourceManual, result.Source) + require.Equal(t, 1, calls, "forced run calls the model once") + }) + } +} + +// TestGenerateCompaction_DefaultSourceAutomatic verifies an unforced +// over-threshold run reports the automatic source by default. +func TestGenerateCompaction_DefaultSourceAutomatic(t *testing.T) { + t.Parallel() + + model := &chattest.FakeModel{ + ProviderName: "fake", + ModelName: "fake-model", + GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) { + return &fantasy.Response{ + Content: []fantasy.Content{ + fantasy.TextContent{Text: "auto summary"}, + }, + }, nil + }, + } + result, err := GenerateCompaction(context.Background(), GenerateCompactionOptions{ + Model: model, + Messages: []fantasy.Message{textMessage(fantasy.MessageRoleUser, "hello")}, + ThresholdPercent: 70, + ContextLimit: 100, + StepUsage: fantasy.Usage{InputTokens: 90}, + }) + require.NoError(t, err) + require.Equal(t, "auto summary", result.SummaryReport) + require.Equal(t, CompactionSourceAutomatic, result.Source) +} diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go new file mode 100644 index 00000000000..a344888d33f --- /dev/null +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -0,0 +1,217 @@ +package chatstate_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatstate" + "github.com/coder/coder/v2/testutil" +) + +// RequestCompaction lifecycle tests. +// +// The compaction_requested_at marker is one-shot by construction: +// executionStateUpdate clears it unless a transition explicitly +// carries it forward. These tests pin the intended carriers +// (ownership changes, queue appends) and the intended consumers +// (compaction commit, turn-terminal transitions). + +// requestCompaction seeds an idle chat and records a manual +// compaction request, returning the machine for follow-up +// transitions. +func requestCompaction(t *testing.T, f *testFixture) (uuid.UUID, *chatstate.ChatMachine) { + t.Helper() + ctx := testutil.Context(t, testutil.WaitShort) + seeded := seedState(t, f, chatstate.StateW) + m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) + return err + })) + chat := f.readChat(ctx, t, seeded.chatID) + require.True(t, chat.CompactionRequestedAt.Valid, "request must set the marker") + require.Equal(t, database.ChatStatusRunning, chat.Status) + return seeded.chatID, m +} + +// TestRequestCompaction_PreservedByAcquireAndQueueAppend verifies the +// marker survives worker acquisition and queued message appends: both +// happen between the request and the compaction commit in normal +// operation. +func TestRequestCompaction_PreservedByAcquireAndQueueAppend(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + chatID, m := requestCompaction(t, f) + + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.Acquire(chatstate.AcquireInput{WorkerID: uuid.New(), RunnerID: uuid.New()}) + return err + })) + chat := f.readChat(ctx, t, chatID) + require.True(t, chat.CompactionRequestedAt.Valid, "Acquire preserves the marker") + + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.SendMessage(chatstate.SendMessageInput{ + Message: userTextMessage("queued while compacting", f.User.ID, f.Model.ID), + BusyBehavior: chatstate.BusyBehaviorQueue, + }) + return err + })) + chat = f.readChat(ctx, t, chatID) + require.True(t, chat.CompactionRequestedAt.Valid, "queue append preserves the marker") +} + +// TestRequestCompaction_ConsumedByCommitStep verifies the compaction +// commit path clears the marker exactly once, and that a plain +// CommitStep without ConsumeCompactionRequest leaves it alone. +func TestRequestCompaction_ConsumedByCommitStep(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + chatID, m := requestCompaction(t, f) + + assistant := userTextMessage("mid-step", f.User.ID, f.Model.ID) + assistant.Role = database.ChatMessageRoleAssistant + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.CommitStep(chatstate.CommitStepInput{ + Messages: []chatstate.Message{assistant}, + }) + return err + })) + chat := f.readChat(ctx, t, chatID) + require.True(t, chat.CompactionRequestedAt.Valid, + "CommitStep without ConsumeCompactionRequest preserves the marker") + + summary := userTextMessage("summary", f.User.ID, f.Model.ID) + summary.Role = database.ChatMessageRoleAssistant + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.CommitStep(chatstate.CommitStepInput{ + Messages: []chatstate.Message{summary}, + ConsumeCompactionRequest: true, + }) + return err + })) + chat = f.readChat(ctx, t, chatID) + require.False(t, chat.CompactionRequestedAt.Valid, + "CommitStep with ConsumeCompactionRequest clears the marker") +} + +// TestRequestCompaction_ClearedOnTerminalTransitions verifies that +// every turn-terminal transition reachable from a pending request +// clears the marker so it never replays on a later turn. +func TestRequestCompaction_ClearedOnTerminalTransitions(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + apply func(tx *chatstate.Tx) error + }{ + { + name: "FinishTurn", + apply: func(tx *chatstate.Tx) error { + _, err := tx.FinishTurn(chatstate.FinishTurnInput{}) + return err + }, + }, + { + name: "FinishError", + apply: func(tx *chatstate.Tx) error { + _, err := tx.FinishError(chatstate.FinishErrorInput{ + LastError: pqtype.NullRawMessage{ + RawMessage: json.RawMessage(`{"message":"boom"}`), + Valid: true, + }, + }) + return err + }, + }, + { + name: "Interrupt", + apply: func(tx *chatstate.Tx) error { + _, err := tx.Interrupt(chatstate.InterruptInput{}) + return err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + chatID, m := requestCompaction(t, f) + + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + return tc.apply(tx) + })) + chat := f.readChat(ctx, t, chatID) + require.False(t, chat.CompactionRequestedAt.Valid, + "%s must clear the compaction request marker", tc.name) + }) + } +} + +// TestRequestCompaction_ClearedByNewTurn verifies transitions that +// start a fresh turn (direct sends, edits) drop a pending request: +// the new turn's history supersedes the compaction the user asked for. +func TestRequestCompaction_ClearedByNewTurn(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + chatID, m := requestCompaction(t, f) + + // Finish the pending turn (clears), then re-request and edit. + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.FinishTurn(chatstate.FinishTurnInput{}) + return err + })) + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) + return err + })) + + target := firstUserMessageID(ctx, t, f, chatID) + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.EditMessage(chatstate.EditMessageInput{ + MessageID: target, + CreatedBy: f.User.ID, + Content: userTextMessage("edited", f.User.ID, f.Model.ID).Content, + }) + return err + })) + chat := f.readChat(ctx, t, chatID) + require.False(t, chat.CompactionRequestedAt.Valid, + "EditMessage starts a new turn and must clear the marker") +} + +// TestRequestCompaction_RejectedWhenBusyOrArchived pins the matrix +// boundaries callers rely on for 409 mapping: only W admits the +// transition. +func TestRequestCompaction_RejectedWhenBusyOrArchived(t *testing.T) { + t.Parallel() + + for _, from := range []chatstate.ExecutionState{ + chatstate.StateR0, chatstate.StateE0, chatstate.StateXW, + } { + t.Run(string(from), func(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + seeded := seedState(t, f, from) + m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) + + err := m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, rerr := tx.RequestCompaction(chatstate.RequestCompactionInput{}) + return rerr + }) + require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed) + chat := f.readChat(ctx, t, seeded.chatID) + require.False(t, chat.CompactionRequestedAt.Valid) + }) + } +} diff --git a/coderd/x/chatd/chatstate/transition.go b/coderd/x/chatd/chatstate/transition.go index d96a91fef91..f7b6c3634c5 100644 --- a/coderd/x/chatd/chatstate/transition.go +++ b/coderd/x/chatd/chatstate/transition.go @@ -14,6 +14,7 @@ const ( TransitionSetArchived Transition = "SetArchived" TransitionSendMessage Transition = "SendMessage" TransitionEditMessage Transition = "EditMessage" + TransitionRequestCompaction Transition = "RequestCompaction" TransitionDeleteQueuedMessage Transition = "DeleteQueuedMessage" TransitionPromoteQueuedMessage Transition = "PromoteQueuedMessage" TransitionInterrupt Transition = "Interrupt" @@ -44,6 +45,7 @@ var AllExecutionTransitions = []Transition{ TransitionSetArchived, TransitionSendMessage, TransitionEditMessage, + TransitionRequestCompaction, TransitionDeleteQueuedMessage, TransitionPromoteQueuedMessage, TransitionInterrupt, @@ -75,9 +77,10 @@ var transitionMatrix = map[ExecutionState]map[Transition][]ExecutionState{ TransitionCreateChat: {StateR0}, }, StateW: { - TransitionSetArchived: {StateXW}, - TransitionSendMessage: {StateR0}, - TransitionEditMessage: {StateR0}, + TransitionSetArchived: {StateXW}, + TransitionSendMessage: {StateR0}, + TransitionEditMessage: {StateR0}, + TransitionRequestCompaction: {StateR0}, }, StateE0: { TransitionSetArchived: {StateXE0}, diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 7b4eb1daf1f..4103dedfd22 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -141,7 +141,14 @@ func CreateChat( // applyExecutionStateUpdate is a small adapter so transition methods // do not have to repeat the UpdateChatExecutionState boilerplate. // The state machine writes status, archived, last_error, ownership -// identifiers, and the requires-action deadline as one atomic update. +// identifiers, the requires-action deadline, and the manual +// compaction request marker as one atomic update. +// +// CompactionRequestedAt is one-shot by construction: leaving it at +// its zero value clears any pending manual compaction request, so a +// stale request can never replay on a later turn. Transitions that +// must keep a pending request alive (archive toggles, ownership +// changes, queue appends) explicitly carry the current value forward. type executionStateUpdate struct { Status database.ChatStatus Archived bool @@ -149,6 +156,7 @@ type executionStateUpdate struct { RunnerID uuid.NullUUID LastError pqtype.NullRawMessage RequiresActionDeadlineAt sql.NullTime + CompactionRequestedAt sql.NullTime } func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) { @@ -160,6 +168,7 @@ func (tx *Tx) applyExecutionState(u executionStateUpdate) (database.Chat, error) RunnerID: u.RunnerID, LastError: u.LastError, RequiresActionDeadlineAt: u.RequiresActionDeadlineAt, + CompactionRequestedAt: u.CompactionRequestedAt, }) } @@ -285,6 +294,7 @@ func (tx *Tx) SetArchived(input SetArchivedInput) (SetArchivedResult, error) { RunnerID: chat.RunnerID, LastError: chat.LastError, RequiresActionDeadlineAt: chat.RequiresActionDeadlineAt, + CompactionRequestedAt: chat.CompactionRequestedAt, }); err != nil { return SetArchivedResult{}, xerrors.Errorf("update archive: %w", err) } @@ -456,6 +466,9 @@ func (tx *Tx) sendMessageQueueAndSetStatus( if err != nil { return SendMessageResult{}, xerrors.Errorf("insert queued: %w", err) } + // Queueing does not start a new turn, so a pending manual + // compaction request stays live: the in-flight compaction + // commits first and the queued message is promoted afterwards. if _, err := tx.applyExecutionState(executionStateUpdate{ Status: status, Archived: false, @@ -463,6 +476,7 @@ func (tx *Tx) sendMessageQueueAndSetStatus( RunnerID: chat.RunnerID, LastError: lastError, RequiresActionDeadlineAt: deadline, + CompactionRequestedAt: chat.CompactionRequestedAt, }); err != nil { return SendMessageResult{}, xerrors.Errorf("update status: %w", err) } @@ -609,6 +623,45 @@ func (tx *Tx) EditMessage(input EditMessageInput) (EditMessageResult, error) { }, nil } +// RequestCompactionInput is intentionally empty. The compaction turn +// derives AI Gateway attribution from the owner's synthetic API key, +// so the request carries no caller input. +type RequestCompactionInput struct{} + +// RequestCompactionResult is returned by [Tx.RequestCompaction]. +type RequestCompactionResult struct { + Chat database.Chat +} + +// RequestCompaction records a manual compaction request on an idle +// chat and moves it to running so a worker picks it up. No message is +// inserted; the request is a one-shot marker consumed by the worker's +// compaction commit and cleared by every transition that starts a new +// turn or leaves running, so a stale request never replays later. +func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResult, error) { + chat, _, err := tx.requireFromAllowed(TransitionRequestCompaction) + if err != nil { + return RequestCompactionResult{}, err + } + now, err := tx.store.GetDatabaseNow(tx.ctx) + if err != nil { + return RequestCompactionResult{}, xerrors.Errorf("get db now: %w", err) + } + updated, err := tx.applyExecutionState(executionStateUpdate{ + Status: database.ChatStatusRunning, + Archived: false, + WorkerID: chat.WorkerID, + RunnerID: chat.RunnerID, + LastError: chat.LastError, + RequiresActionDeadlineAt: sql.NullTime{}, + CompactionRequestedAt: sql.NullTime{Time: now, Valid: true}, + }) + if err != nil { + return RequestCompactionResult{}, xerrors.Errorf("set running: %w", err) + } + return RequestCompactionResult{Chat: updated}, nil +} + // DeleteQueuedMessageInput configures [Tx.DeleteQueuedMessage]. type DeleteQueuedMessageInput struct { QueuedMessageID int64 @@ -954,6 +1007,7 @@ func (tx *Tx) Acquire(input AcquireInput) (AcquireResult, error) { RunnerID: uuid.NullUUID{UUID: input.RunnerID, Valid: true}, LastError: chat.LastError, RequiresActionDeadlineAt: chat.RequiresActionDeadlineAt, + CompactionRequestedAt: chat.CompactionRequestedAt, }); err != nil { return AcquireResult{}, xerrors.Errorf("set ownership: %w", err) } @@ -997,6 +1051,7 @@ func (tx *Tx) Abandon(_ AbandonInput) (AbandonResult, error) { RunnerID: uuid.NullUUID{}, LastError: chat.LastError, RequiresActionDeadlineAt: chat.RequiresActionDeadlineAt, + CompactionRequestedAt: chat.CompactionRequestedAt, }); err != nil { return AbandonResult{}, xerrors.Errorf("clear ownership: %w", err) } @@ -1069,6 +1124,11 @@ func (tx *Tx) RecordRetryState(input RecordRetryStateInput) (RecordRetryStateRes // CommitStepInput configures [Tx.CommitStep]. type CommitStepInput struct { Messages []Message + // ConsumeCompactionRequest clears the one-shot manual compaction + // marker atomically with the committed step. Compaction commits + // set this so a request is consumed exactly once and never + // replays on a later turn. + ConsumeCompactionRequest bool } // CommitStepResult is returned by [Tx.CommitStep]. @@ -1079,7 +1139,7 @@ type CommitStepResult struct { // CommitStep stores one durable message suffix while remaining // running. func (tx *Tx) CommitStep(input CommitStepInput) (CommitStepResult, error) { - _, from, err := tx.requireFromAllowed(TransitionCommitStep) + chat, from, err := tx.requireFromAllowed(TransitionCommitStep) if err != nil { return CommitStepResult{}, err } @@ -1093,6 +1153,19 @@ func (tx *Tx) CommitStep(input CommitStepInput) (CommitStepResult, error) { if err != nil { return CommitStepResult{}, xerrors.Errorf("insert commit step messages: %w", err) } + if input.ConsumeCompactionRequest && chat.CompactionRequestedAt.Valid { + if _, err := tx.applyExecutionState(executionStateUpdate{ + Status: chat.Status, + Archived: chat.Archived, + WorkerID: chat.WorkerID, + RunnerID: chat.RunnerID, + LastError: chat.LastError, + RequiresActionDeadlineAt: chat.RequiresActionDeadlineAt, + CompactionRequestedAt: sql.NullTime{}, + }); err != nil { + return CommitStepResult{}, xerrors.Errorf("consume compaction request: %w", err) + } + } return CommitStepResult{ InsertedMessages: inserted, }, nil diff --git a/coderd/x/chatd/chatstate/transitions_matrix_test.go b/coderd/x/chatd/chatstate/transitions_matrix_test.go index cc2e5793821..1f1597f498b 100644 --- a/coderd/x/chatd/chatstate/transitions_matrix_test.go +++ b/coderd/x/chatd/chatstate/transitions_matrix_test.go @@ -236,6 +236,13 @@ func applyFinishInterruption(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ s return err } +func applyRequestCompaction(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error { + t.Helper() + var err error + result.requestCompaction, err = tx.RequestCompaction(chatstate.RequestCompactionInput{}) + return err +} + func applyFinishTurn(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error { t.Helper() var err error @@ -282,6 +289,8 @@ func defaultApplier(tr chatstate.Transition) applierFn { return applySendMessageQueue case chatstate.TransitionEditMessage: return applyEditMessage + case chatstate.TransitionRequestCompaction: + return applyRequestCompaction case chatstate.TransitionDeleteQueuedMessage: return applyDeleteQueuedMessage case chatstate.TransitionPromoteQueuedMessage: @@ -333,6 +342,7 @@ func mustMarshalParts(t *testing.T, parts []codersdk.ChatMessagePart) pqtype.Nul type transitionCaseResult struct { sendMessage chatstate.SendMessageResult editMessage chatstate.EditMessageResult + requestCompaction chatstate.RequestCompactionResult deleteQueuedMessage chatstate.DeleteQueuedMessageResult promoteQueuedMessage chatstate.PromoteQueuedMessageResult interrupt chatstate.InterruptResult @@ -778,6 +788,10 @@ func matrixCases() []transitionCaseSpec { editMessageCase(chatstate.StateA0), editMessageCase(chatstate.StateA1), + // RequestCompaction: only from idle (W), lands in R0 with + // the one-shot marker set and no history/queue mutation. + requestCompactionCase(), + // DeleteQueuedMessage cases. Empty-tail want collapses the // classified state (E1->E0, R1->R0, I1->I0, A1->A0). The // non-empty-tail cases need a multi-queued seed. @@ -1417,6 +1431,31 @@ func promoteQueuedCase(from, want chatstate.ExecutionState, shape queueShape, ta return spec } +func requestCompactionCase() transitionCaseSpec { + return transitionCaseSpec{ + transition: chatstate.TransitionRequestCompaction, + from: chatstate.StateW, + want: chatstate.StateR0, + apply: applyRequestCompaction, + assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) { + after, err := f.DB.GetChatByID(ctx, seeded.chatID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusRunning, after.Status, + "RequestCompaction sets status running") + require.True(t, after.CompactionRequestedAt.Valid, + "RequestCompaction sets compaction_requested_at") + require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID), + "RequestCompaction inserts no history messages") + require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID), + "RequestCompaction leaves the queue untouched") + require.Equal(t, after.ID, result.requestCompaction.Chat.ID, + "RequestCompaction returns the updated chat row") + require.True(t, result.requestCompaction.Chat.CompactionRequestedAt.Valid, + "returned chat row carries the compaction request marker") + }, + } +} + func interruptCase(from, want chatstate.ExecutionState) transitionCaseSpec { return transitionCaseSpec{ transition: chatstate.TransitionInterrupt, diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index 9dad61bb87c..443909157df 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -134,6 +134,9 @@ type generationDecision struct { pendingDynamicToolCalls []pendingDynamicToolCall finishReason generationFinishReason promotedMessageID int64 + // forced marks a compact action triggered by a manual + // compaction request rather than the usage threshold. + forced bool } type generationRetryDecision struct { @@ -204,6 +207,20 @@ func decideGenerationAction(input generationDecisionInput) (generationDecision, return generationDecision{kind: generationActionEnterRequiresAction, pendingDynamicToolCalls: dynamicCalls}, nil } + // A manual compaction request wins over every non-tool decision: + // idle chats would otherwise finish the turn via the + // history-complete check before ever compacting. The request is + // ignored when nothing after the latest boundary is compactable + // (for example the history was edited between request and + // execution); the stale marker is then cleared by the terminal + // transition of this turn. + if input.chat.CompactionRequestedAt.Valid { + boundary := latestCompactionBoundaryIndex(input.messages) + if _, ok := firstUncompressedAssistantAfter(input.messages, boundary); ok { + return generationDecision{kind: generationActionCompact, forced: true}, nil + } + } + stopAfter, err := historyHasStopAfterToolResult(input.messages, input.stopAfterTools) if err != nil { return generationDecision{}, err @@ -377,7 +394,7 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS case generationActionExecuteLocalTools: actionErr = s.executeLocalTools(ctx, machine, input, prepared, decision) case generationActionCompact: - actionErr = s.generateCompaction(ctx, machine, input, prepared) + actionErr = s.generateCompaction(ctx, machine, input, prepared, compactionSourceForDecision(decision)) default: return s.finishGenerationError(ctx, machine, input, xerrors.Errorf("unknown generation action %q", decision.kind), generationAttemptNotRequired) } @@ -684,11 +701,22 @@ func (s *taskStarter) executeLocalTools( return s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionExecuteLocalTools, messages) } +// compactionSourceForDecision maps a compact decision to the +// compaction source recorded in the summary messages. Manual +// requests also force the compaction past the usage-threshold gates. +func compactionSourceForDecision(decision generationDecision) chatloop.CompactionSource { + if decision.forced { + return chatloop.CompactionSourceManual + } + return chatloop.CompactionSourceAutomatic +} + func (s *taskStarter) generateCompaction( ctx context.Context, machine *chatstate.ChatMachine, input chatWorkerTaskStartInput, prepared generationPrepared, + source chatloop.CompactionSource, ) error { attempt, err := s.beginGenerationAttempt(ctx, machine, input) if err != nil { @@ -724,6 +752,8 @@ func (s *taskStarter) generateCompaction( ) } compactionOpts.PublishMessagePart = attempt.publish + compactionOpts.Source = source + compactionOpts.Force = source == chatloop.CompactionSourceManual // Attach the turn debug run so the compaction call records a child // debug run; without it startCompactionDebugRun finds no parent and // skips debug instrumentation entirely. @@ -750,8 +780,9 @@ func (s *taskStarter) generateCompaction( return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number)) } err = s.commitGenerationStep(ctx, machine, input, attempt.number, generationActionCompact, stepMessagesForCommit{ - Messages: messages.Messages, - VisibleIndexes: visibleMessageIndexes(messages.Messages), + Messages: messages.Messages, + VisibleIndexes: visibleMessageIndexes(messages.Messages), + ConsumeCompactionRequest: true, }) s.server.metrics.RecordCompaction(metricProvider, metricModel, err == nil, err) if err != nil { @@ -857,7 +888,10 @@ func (s *taskStarter) commitGenerationStep( if _, err := loadChatForGeneration(ctx, store, input, requireGenerationAttempt(attempt)); err != nil { return xerrors.Errorf("load chat for generation: %w", err) } - commitResult, err := tx.CommitStep(chatstate.CommitStepInput{Messages: messages.Messages}) + commitResult, err := tx.CommitStep(chatstate.CommitStepInput{ + Messages: messages.Messages, + ConsumeCompactionRequest: messages.ConsumeCompactionRequest, + }) if err != nil { return xerrors.Errorf("tx.CommitStep: %w", err) } diff --git a/coderd/x/chatd/message_conversion.go b/coderd/x/chatd/message_conversion.go index 2197e0aaab4..c70f1efcd8c 100644 --- a/coderd/x/chatd/message_conversion.go +++ b/coderd/x/chatd/message_conversion.go @@ -17,6 +17,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatcost" + "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/chatd/chattool" @@ -38,6 +39,9 @@ type buildCommitStepMessagesInput struct { type stepMessagesForCommit struct { Messages []chatstate.Message VisibleIndexes []int + // ConsumeCompactionRequest clears the manual compaction marker + // atomically with the commit. Set on compaction commits. + ConsumeCompactionRequest bool } func buildCommitStepMessages(input buildCommitStepMessagesInput) (stepMessagesForCommit, error) { @@ -280,8 +284,12 @@ func buildCompactionMessages(input buildCompactionMessagesInput) (compactionMess if err != nil { return compactionMessagesForCommit{}, xerrors.Errorf("marshal compaction system summary: %w", err) } + source := input.compaction.Source + if source == "" { + source = chatloop.CompactionSourceAutomatic + } args, err := json.Marshal(map[string]any{ - "source": "automatic", + "source": source, "threshold_percent": input.compaction.ThresholdPercent, }) if err != nil { @@ -295,7 +303,7 @@ func buildCompactionMessages(input buildCompactionMessagesInput) (compactionMess } summaryResult, err := json.Marshal(map[string]any{ "summary": input.compaction.SummaryReport, - "source": "automatic", + "source": source, "threshold_percent": input.compaction.ThresholdPercent, "usage_percent": input.compaction.UsagePercent, "context_tokens": input.compaction.ContextTokens, diff --git a/coderd/x/chatd/message_conversion_test.go b/coderd/x/chatd/message_conversion_test.go index 4ec63a774c6..4a14d3cd34a 100644 --- a/coderd/x/chatd/message_conversion_test.go +++ b/coderd/x/chatd/message_conversion_test.go @@ -17,6 +17,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/messagepartbuffer" "github.com/coder/coder/v2/codersdk" @@ -293,6 +294,134 @@ func TestDecisionCompactsAgainAfterPostCompactionTurn(t *testing.T) { require.Equal(t, generationActionCompact, decision.kind) } +func TestBuildCompactionMessages_ManualSource(t *testing.T) { + t.Parallel() + + got, err := buildCompactionMessages(buildCompactionMessagesInput{ + modelConfigID: uuid.New(), + contentVersion: chatprompt.CurrentContentVersion, + toolCallID: "summary-1", + toolName: "chat_summarized", + compaction: compactionOutcome{ + SystemSummary: "system summary", + SummaryReport: "user report", + Source: chatloop.CompactionSourceManual, + ThresholdPercent: 70, + UsagePercent: 10, + ContextTokens: 100, + ContextLimit: 1000, + }, + }) + require.NoError(t, err) + require.Len(t, got.Messages, 3) + + callPart := parseMessageParts(t, got.Messages[1].Role, got.Messages[1].Content)[0] + require.JSONEq(t, `{"source":"manual","threshold_percent":70}`, string(callPart.Args)) + resultPart := parseMessageParts(t, got.Messages[2].Role, got.Messages[2].Content)[0] + require.JSONEq(t, `{"summary":"user report","source":"manual","threshold_percent":70,"usage_percent":10,"context_tokens":100,"context_limit_tokens":1000}`, string(resultPart.Result)) +} + +// TestDecisionForcedCompaction verifies the manual compaction request +// ordering contract: a pending request beats the history-complete +// FinishTurn decision on idle chats, loses to unresolved tool calls, +// and is skipped when nothing after the latest boundary is +// compactable. +func TestDecisionForcedCompaction(t *testing.T) { + t.Parallel() + + requestedChat := database.Chat{ + CompactionRequestedAt: sql.NullTime{Time: time.Now(), Valid: true}, + } + + t.Run("beats history complete", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("question")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("answer")), + } + decision, err := decideGenerationAction(generationDecisionInput{ + chat: requestedChat, + messages: messages, + }) + require.NoError(t, err) + require.Equal(t, generationActionCompact, decision.kind) + require.True(t, decision.forced) + }) + + t.Run("loses to unresolved tool calls", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("question")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageToolCall("read-1", "read_file", json.RawMessage(`{}`))), + } + decision, err := decideGenerationAction(generationDecisionInput{ + chat: requestedChat, + messages: messages, + }) + require.NoError(t, err) + require.Equal(t, generationActionExecuteLocalTools, decision.kind) + require.False(t, decision.forced) + }) + + t.Run("skipped when nothing compactable", func(t *testing.T) { + t.Parallel() + + // Everything up to and including the latest boundary is + // compressed; no uncompressed assistant follows, so the + // forced compact is skipped and the normal decision applies + // (after-compaction histories continue with an assistant + // generation, exactly as if no request were pending). + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, true, codersdk.ChatMessageText("summary")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, true, codersdk.ChatMessageToolCall("summary-1", "chat_summarized", nil)), + dbMessage(t, 3, database.ChatMessageRoleTool, true, codersdk.ChatMessageToolResult("summary-1", "chat_summarized", json.RawMessage(`{}`), false, false)), + dbMessage(t, 4, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("follow-up question")), + dbMessage(t, 5, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("follow-up answer")), + } + // Only messages up to the boundary: strip the follow-up. + requested, err := decideGenerationAction(generationDecisionInput{ + chat: requestedChat, + messages: messages[:3], + }) + require.NoError(t, err) + unrequested, err := decideGenerationAction(generationDecisionInput{ + chat: database.Chat{}, + messages: messages[:3], + }) + require.NoError(t, err) + require.Equal(t, unrequested.kind, requested.kind, + "stale request must not change the decision") + require.False(t, requested.forced) + + // With an uncompressed assistant after the boundary the + // forced compact fires again. + decision, err := decideGenerationAction(generationDecisionInput{ + chat: requestedChat, + messages: messages, + }) + require.NoError(t, err) + require.Equal(t, generationActionCompact, decision.kind) + require.True(t, decision.forced) + }) + + t.Run("no request follows normal decision", func(t *testing.T) { + t.Parallel() + + messages := []database.ChatMessage{ + dbMessage(t, 1, database.ChatMessageRoleUser, false, codersdk.ChatMessageText("question")), + dbMessage(t, 2, database.ChatMessageRoleAssistant, false, codersdk.ChatMessageText("answer")), + } + decision, err := decideGenerationAction(generationDecisionInput{ + chat: database.Chat{}, + messages: messages, + }) + require.NoError(t, err) + require.Equal(t, generationActionFinishTurn, decision.kind) + }) +} + func TestCompactionStatusFromHistory(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index 1a82c205d1d..c6990e8a776 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -3426,6 +3426,25 @@ func (c *ExperimentalClient) InterruptChat(ctx context.Context, chatID uuid.UUID return chat, json.NewDecoder(res.Body).Decode(&chat) } +// CompactChat requests a manual context compaction on an idle chat. +// The compaction runs asynchronously through the chat worker and +// bypasses the automatic usage threshold; the chat returns to waiting +// once the summary is committed. +func (c *ExperimentalClient) CompactChat(ctx context.Context, chatID uuid.UUID) (Chat, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/compact", chatID), nil) + if err != nil { + return Chat{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + // Compaction runs LLM inference, so spend-limit rejections + // carry the structured usage-limit payload. + return Chat{}, readBodyAsChatUsageLimitError(res) + } + var chat Chat + return chat, json.NewDecoder(res.Body).Decode(&chat) +} + // ReconcileInvalidChatState recovers a chat stuck in an invalid // execution state, moving it into an error state from which the caller // can send a new message or edit history to continue. diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 43b0f413f64..af5967e908a 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
icontrue
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_reasoning_effortfalse
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
icontrue
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
compaction_requested_atfalse
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_reasoning_effortfalse
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
| diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 47a475786c3..c0ea1ff1c68 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -92,6 +92,10 @@ messages remain in the database and are still visible to users, but are excluded from the model's context window. This happens transparently and keeps long-running sessions productive. +You can also trigger a compaction on demand by sending `/compact` while the +agent is idle. Manual compaction runs the same summarization regardless of +current token usage and is labeled as manual in the conversation. + ### Message queuing Users can send follow-up messages while the agent is actively working. Messages diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 1b89aeb459d..2e3f2740b47 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -1043,6 +1043,240 @@ Experimental: this endpoint is subject to change. To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Compact chat + +### Code samples + +```sh +# Example request using curl +curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/compact \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/experimental/chats/{chat}/compact` + +Experimental: this endpoint is subject to change. +Requests a manual context compaction on an idle chat. The +compaction runs asynchronously through the chat worker and +bypasses the automatic usage threshold. + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------|----------|-------------| +| `chat` | path | string(uuid) | true | Chat ID | + +### Example responses + +> 200 Response + +```json +{ + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "archived": true, + "build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb", + "children": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "archived": true, + "build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb", + "children": [], + "client_type": "ui", + "context": { + "dirty": true, + "dirty_since": "2019-08-24T14:15:22Z", + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] + }, + "created_at": "2019-08-24T14:15:22Z", + "diff_status": { + "additions": 0, + "approved": true, + "author_avatar_url": "string", + "author_login": "string", + "base_branch": "string", + "changed_files": 0, + "changes_requested": true, + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "commits": 0, + "deletions": 0, + "head_branch": "string", + "pr_number": 0, + "pull_request_draft": true, + "pull_request_state": "string", + "pull_request_title": "string", + "refreshed_at": "2019-08-24T14:15:22Z", + "reviewer_count": 0, + "stale_at": "2019-08-24T14:15:22Z", + "url": "string" + }, + "files": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "mime_type": "string", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" + } + ], + "has_unread": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "labels": { + "property1": "string", + "property2": "string" + }, + "last_error": { + "detail": "string", + "kind": "generic", + "message": "string", + "provider": "string", + "retryable": true, + "status_code": 0 + }, + "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", + "last_turn_summary": "string", + "mcp_server_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "owner_username": "string", + "parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359", + "pin_order": 0, + "plan_mode": "plan", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, + "status": "waiting", + "title": "string", + "updated_at": "2019-08-24T14:15:22Z", + "warnings": [ + "string" + ], + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], + "client_type": "ui", + "context": { + "dirty": true, + "dirty_since": "2019-08-24T14:15:22Z", + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] + }, + "created_at": "2019-08-24T14:15:22Z", + "diff_status": { + "additions": 0, + "approved": true, + "author_avatar_url": "string", + "author_login": "string", + "base_branch": "string", + "changed_files": 0, + "changes_requested": true, + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "commits": 0, + "deletions": 0, + "head_branch": "string", + "pr_number": 0, + "pull_request_draft": true, + "pull_request_state": "string", + "pull_request_title": "string", + "refreshed_at": "2019-08-24T14:15:22Z", + "reviewer_count": 0, + "stale_at": "2019-08-24T14:15:22Z", + "url": "string" + }, + "files": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "mime_type": "string", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" + } + ], + "has_unread": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "labels": { + "property1": "string", + "property2": "string" + }, + "last_error": { + "detail": "string", + "kind": "generic", + "message": "string", + "provider": "string", + "retryable": true, + "status_code": 0 + }, + "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", + "last_turn_summary": "string", + "mcp_server_ids": [ + "497f6eca-6276-4993-bfeb-53cbbbba6f08" + ], + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "owner_username": "string", + "parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359", + "pin_order": 0, + "plan_mode": "plan", + "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", + "shared": true, + "status": "waiting", + "title": "string", + "updated_at": "2019-08-24T14:15:22Z", + "warnings": [ + "string" + ], + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Refresh chat context ### Code samples diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 5870b5f2262..50532c6b728 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -483,6 +483,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "generation_attempt": ActionIgnore, // Internal retry counter. "runner_id": ActionIgnore, // Internal ownership identifier. "requires_action_deadline_at": ActionIgnore, // Internal pending-action deadline. + "compaction_requested_at": ActionIgnore, // Internal one-shot manual compaction signal. }, &database.UserSkill{}: { "id": ActionTrack, diff --git a/site/src/api/api.ts b/site/src/api/api.ts index dc3f5e56e2b..6432c0c61ae 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3464,6 +3464,18 @@ class ExperimentalApiMethods { return response.data; }; + /** + * Requests a manual context compaction on an idle chat. The + * compaction runs asynchronously through the chat worker and + * bypasses the automatic usage threshold. + */ + compactChat = async (chatId: string): Promise => { + const response = await this.axios.post( + `/api/experimental/chats/${chatId}/compact`, + ); + return response.data; + }; + /** * Re-pins the chat to its agent's latest context snapshot and clears * the dirty marker. Returns the updated chat. diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index bea51ecd6d9..fdff89970c0 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1449,6 +1449,19 @@ export const interruptChat = (queryClient: QueryClient, chatId: string) => ({ }, }); +export const compactChat = (queryClient: QueryClient, chatId: string) => ({ + mutationFn: () => API.experimental.compactChat(chatId), + onSuccess: () => { + // The compaction transitions the chat to running; the summary + // rows stream in over the websocket like any other turn. + void queryClient.invalidateQueries({ + queryKey: chatKey(chatId), + exact: true, + }); + void invalidateChatDebugRuns(queryClient, chatId); + }, +}); + /** * Re-pins the chat to its agent's latest context snapshot, clearing the * dirty marker. On success the returned chat (carrying the freshly pinned diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index e798e15e03b..f10dba25ab4 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2629,3 +2629,138 @@ export const WithWaitAgentComputerUseVNC: Story = { }); }, }; + +// --------------------------------------------------------------------------- +// /compact slash command +// --------------------------------------------------------------------------- + +const compactCommandMessages: TypesGen.ChatMessagesResponse = { + messages: [ + { + id: 1, + chat_id: CHAT_ID, + role: "user", + created_at: "2024-01-01T00:00:00Z", + content: [{ type: "text", text: "Explain the auth flow" }], + }, + { + id: 2, + chat_id: CHAT_ID, + role: "assistant", + created_at: "2024-01-01T00:00:30Z", + content: [ + { type: "text", text: "The auth flow starts at the login page." }, + ], + }, + ], + queued_messages: [], + has_more: false, +}; + +/** Submitting "/compact" alone requests a manual compaction instead of + * sending a chat message. */ +export const SlashCompactCommandSubmits: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Compact command", + status: "waiting", + }, + compactCommandMessages, + { diffUrl: undefined }, + ), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const compactSpy = spyOn(API.experimental, "compactChat").mockResolvedValue( + { + id: CHAT_ID, + ...baseChatFields, + title: "Compact command", + status: "running", + } as TypesGen.Chat, + ); + const sendSpy = spyOn(API.experimental, "createChatMessage"); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.keyboard("/compact"); + // First Enter accepts the highlighted menu entry; second Enter + // submits the composer. + expect(await within(document.body).findByText("Commands")).toBeVisible(); + await userEvent.keyboard("{Enter}"); + await userEvent.keyboard("{Enter}"); + + await waitFor(() => { + expect(compactSpy).toHaveBeenCalledTimes(1); + }); + expect(compactSpy).toHaveBeenCalledWith(CHAT_ID); + expect(sendSpy).not.toHaveBeenCalled(); + }, +}; + +/** A personal skill named "compact" takes precedence: "/compact" is sent + * as a normal message (skill trigger) and no compaction is requested. */ +export const SlashCompactYieldsToPersonalSkill: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Compact skill precedence", + status: "waiting", + }, + compactCommandMessages, + { diffUrl: undefined }, + ), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([ + { + id: "5f3f847b-6e77-4be4-a591-6c04ee0e0e78", + name: "compact", + description: "Personal compact skill", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const compactSpy = spyOn(API.experimental, "compactChat"); + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockResolvedValue({ + queued: false, + message: { + id: 3, + chat_id: CHAT_ID, + role: "user", + created_at: "2024-01-01T00:01:00Z", + content: [{ type: "text", text: "/compact" }], + }, + }); + + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.click(editor); + await userEvent.keyboard("/compact"); + // The menu offers only the personal skill (the built-in command + // yields); first Enter accepts it, second Enter submits. + expect( + await within(document.body).findByText("Personal compact skill"), + ).toBeVisible(); + await userEvent.keyboard("{Enter}"); + await userEvent.keyboard("{Enter}"); + + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + expect(compactSpy).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index b38bc4380e0..4e38bb22e82 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -31,6 +31,7 @@ import { chatModelConfigs, chatModels, chatProviderConfigs, + compactChat, createChatMessage, deleteChatQueuedMessage, editChatMessage, @@ -45,6 +46,7 @@ import { userCompactionThresholds, } from "#/api/queries/chats"; import { deploymentSSHConfig } from "#/api/queries/deployment"; +import { userSkills } from "#/api/queries/userSkills"; import { preferenceSettings } from "#/api/queries/users"; import { workspaceById, @@ -105,6 +107,10 @@ import { } from "./utils/modelOptions"; import { parsePullRequestUrl } from "./utils/pullRequest"; import { pickReasoningEffort } from "./utils/reasoningEffort"; +import { + COMPACT_SLASH_COMMAND, + chatSlashCommandTriggerText, +} from "./utils/slashCommands"; import { type ChatDetailError, formatUsageLimitMessage, @@ -1015,6 +1021,27 @@ const AgentChatPage: FC = () => { const { isPending: isInterruptPending, mutateAsync: interrupt } = useMutation( interruptChat(queryClient, agentId ?? ""), ); + const { isPending: isCompactPending, mutateAsync: compact } = useMutation( + compactChat(queryClient, agentId ?? ""), + ); + // A personal or workspace skill named "compact" must keep working + // as a skill trigger (read_skill resolves a bare /compact to it), + // so the built-in /compact command yields to both. Shares the + // composer trigger menu's query cache. Until the personal skills + // query succeeds and the chat detail resolves workspace skills, a + // conflict cannot be ruled out, so "/compact" is sent as a regular + // message instead of being intercepted. + const personalSkillsQuery = useQuery({ + ...userSkills(), + staleTime: 60_000, + }); + const chatWorkspaceSkills = workspaceSkillsFromChat(chatQuery.data); + const compactCommandAvailable = + personalSkillsQuery.isSuccess && + chatWorkspaceSkills !== undefined && + ![...personalSkillsQuery.data, ...chatWorkspaceSkills].some( + (skill) => skill.name === COMPACT_SLASH_COMMAND.name, + ); const { mutateAsync: deleteQueuedMessage } = useMutation( deleteChatQueuedMessage(queryClient, agentId ?? ""), ); @@ -1182,7 +1209,7 @@ const AgentChatPage: FC = () => { hasUserFixableModelProviders, }); const isSubmissionPending = - isSendPending || isEditPending || isInterruptPending; + isSendPending || isEditPending || isInterruptPending || isCompactPending; const isChatSettingsPending = isUpdateChatPlanModePending || isUpdateChatWorkspacePending; const isInputDisabled = @@ -1469,6 +1496,47 @@ const AgentChatPage: FC = () => { pendingWorkspaceSyncRef.current, ]); + // "/compact" on its own (no attachments or file references) + // requests a manual context compaction instead of sending a + // message. Only new sends are intercepted; edits keep their + // original meaning, and a personal or workspace skill named + // "compact" takes precedence so the command cannot shadow it. + const isCompactCommand = + editedMessageID === undefined && + compactCommandAvailable && + content.length === 1 && + content[0].type === "text" && + content[0].text?.trim() === + chatSlashCommandTriggerText(COMPACT_SLASH_COMMAND); + if (isCompactCommand) { + // Optimistically show the running state before awaiting so + // a fast compaction cannot race this write: the worker's + // authoritative waiting status may arrive over the stream + // before the POST resolves and must not be overwritten. + const previousSnapshot = store.getSnapshot(); + clearChatErrorReason(agentId); + clearStreamError(); + store.clearStreamState(); + store.setChatStatus("running"); + scrollToBottomRef.current?.(); + try { + await compact(); + } catch (error) { + restoreOptimisticRequestSnapshot(store, previousSnapshot); + if ( + isApiError(error) && + error.response?.status === 409 && + isChatUsageLimitExceededResponse(error.response.data) + ) { + handleUsageLimitError(error); + } else { + toast.error(getErrorMessage(error, "Failed to compact chat.")); + } + throw error; + } + return; + } + if (editedMessageID !== undefined) { const originalEditedMessage = chatMessagesList?.find( (existingMessage) => existingMessage.id === editedMessageID, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index a343bf3e17e..dfe684ad313 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -72,6 +72,7 @@ import { chatAttachmentAcceptAttribute, isChatAttachmentFile, } from "../utils/chatAttachments"; +import type { ChatSlashCommand } from "../utils/slashCommands"; import { AgentSetupNotice } from "./AgentSetupNotice"; import { AttachmentPreview, @@ -205,6 +206,9 @@ interface AgentChatInputProps { // AI Gateway is disabled deployment-wide, independent of provider/model // configuration. Forces the setup notice regardless of the counts above. aiGatewayDisabled?: boolean; + // Built-in commands offered by the "/" trigger menu ahead of + // personal skills. + slashCommands?: readonly ChatSlashCommand[]; } export interface AttachedWorkspaceInfo { @@ -412,6 +416,7 @@ export const AgentChatInput: FC = ({ modelCount, unsupportedProviderNames = [], aiGatewayDisabled, + slashCommands, }) => { const [chatFullWidth] = useChatFullWidth(); const showAgentSetupNotice = @@ -1228,6 +1233,7 @@ export const AgentChatInput: FC = ({ hasWorkspace={hasSkillsWorkspace} workspaceSkills={workspaceSkills} autoFocus + slashCommands={slashCommands} /> {/* Warn about invisible Unicode in the message text. * Unlike the admin/user prompt textareas (which strip diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx index c6ff53285e7..bca2c3a6eba 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -7,15 +7,19 @@ import type { ToolStatus } from "./utils"; /** * Collapsed-by-default rendering for `chat_summarized` tool calls. * Shows "Summarized" and reveals the summary only when expanded. + * Manual compactions (user-requested via /compact) are labeled + * distinctly from automatic threshold-triggered ones. */ export const ChatSummarizedTool: React.FC<{ summary: string; status: ToolStatus; isError: boolean; errorMessage?: string; -}> = ({ summary, status, isError, errorMessage }) => { + source?: string; +}> = ({ summary, status, isError, errorMessage, source }) => { const hasSummary = summary.trim().length > 0; const isRunning = status === "running"; + const isManual = source === "manual"; return ( { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "Summarized" }), + ).toBeInTheDocument(); + }, +}; + +// A user-requested /compact renders with a distinct manual label. +export const ChatSummarizedManual: Story = { + args: { + name: "chat_summarized", + args: JSON.stringify({ source: "manual" }), + result: { summary: "Manual compaction summary text.", source: "manual" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = canvas.getByRole("button", { name: "Summarized (manual)" }); + expect(toggle).toBeInTheDocument(); + + await userEvent.click(toggle); + + expect( + await canvas.findByText((text) => + text.includes("Manual compaction summary text."), + ), + ).toBeInTheDocument(); + }, +}; + +// While the summary streams in, the manual source is only present in +// the call args; the header still shows the running label. +export const ChatSummarizedManualRunning: Story = { + args: { + name: "chat_summarized", + args: JSON.stringify({ source: "manual" }), + status: "running", + result: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Summarizing…")).toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // SubagentInterrupt stories // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index a1bfcc22077..c55f76160f8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -640,6 +640,7 @@ const ReadTemplateRenderer: FC = ({ const ChatSummarizedRenderer: FC = ({ status, + args, result, isError, }) => { @@ -647,6 +648,12 @@ const ChatSummarizedRenderer: FC = ({ const summary = (rec ? asString(rec.summary) : "") || (typeof result === "string" ? result : ""); + // The result carries the source once committed; while streaming, + // only the call args are available. + const argsRec = parseArgs(args); + const source = + (rec ? asString(rec.source) : "") || + (argsRec ? asString(argsRec.source) : ""); return ( = ({ status={status} isError={isError} errorMessage={rec ? asString(rec.error || rec.message) : undefined} + source={source || undefined} /> ); }; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index bc178a3fb35..9adecdf6811 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -2,6 +2,7 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import { type PropsWithChildren, useEffect } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; +import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands"; import { ChatMessageInput } from "./ChatMessageInput"; import type { SkillMetadata } from "./SkillsTriggerMenu"; import { @@ -362,6 +363,112 @@ export const BackspaceClosesMenuWithoutRepositioning: Story = { }, }; +// Built-in commands (e.g. /compact) render in a "Commands" group +// ahead of personal skills when the parent provides slashCommands. +export const CommandsGroupWithSkills: Story = { + args: { + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + expect(await findVisibleText("Commands")).toBeDefined(); + expect(await findVisibleText("/compact")).toBeDefined(); + expect(await findVisibleText("Personal skills")).toBeDefined(); + expect(await findVisibleText("/reviewer")).toBeDefined(); + }, +}; + +// Unlike the skills-only menu, "/" still opens when built-in commands +// exist and the user has no personal skills. +export const CommandsOnlyOpensWithEmptySkills: Story = { + args: { + personalSkillsOverride: [], + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + expect(await findVisibleText("/compact")).toBeDefined(); + expectNoVisibleTextImmediately("No personal skills found."); + }, +}; + +export const EnterSelectsCommand: Story = { + args: { + personalSkillsOverride: [], + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/comp"); + await findVisibleText("/compact"); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(editor.textContent).toBe("/compact"); + }); + }, +}; + +// Commands are first in the combined list, so the first ArrowDown +// moves the highlight from the command into the skills group. +export const ArrowKeysCrossCommandAndSkillGroups: Story = { + args: { + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/"); + await findVisibleText("/compact"); + await userEvent.keyboard("{ArrowDown}{Enter}"); + await waitFor(() => { + expect(editor.textContent).toBe("/docs"); + }); + }, +}; + +// A workspace skill named like a built-in command owns the trigger +// (read_skill resolves a bare /compact to it), so the command stands +// down and only the skill entry is offered. +export const CommandStandsDownForCollidingWorkspaceSkill: Story = { + args: { + hasWorkspace: true, + workspaceSkills: [ + { name: "compact", description: "Workspace compact process." }, + ], + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/comp"); + expect(await findVisibleText("/workspace/compact")).toBeDefined(); + expectNoVisibleTextImmediately("Commands"); + }, +}; + +// While workspace skills are still unknown, a collision cannot be +// ruled out, so built-in commands are not offered yet. +export const CommandsHiddenWhileWorkspaceSkillsUnknown: Story = { + args: { + hasWorkspace: true, + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + expect(await findVisibleText("/personal/reviewer")).toBeDefined(); + expectNoVisibleTextImmediately("Commands"); + }, +}; + +// A query that matches no command hides the Commands group but keeps +// matching skills visible. +export const CommandsFilteredOutBySkillQuery: Story = { + args: { + slashCommands: [COMPACT_SLASH_COMMAND], + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/rev"); + expect(await findVisibleText("/reviewer")).toBeDefined(); + await expectNoVisibleText("/compact"); + expectNoVisibleTextImmediately("Commands"); + }, +}; + // Stories below verify that on mobile viewports, the skills popup // sits directly above the chat input rather than being clipped // above the visible viewport. diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index c7586d184d9..fdb04d3e21e 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -46,6 +46,7 @@ import { filterSkillsByQuery, isPersonalSkillTriggerToken, } from "../../utils/personalSkills"; +import type { ChatSlashCommand } from "../../utils/slashCommands"; import { $createFileReferenceNode, FileReferenceNode, @@ -59,6 +60,7 @@ import { type PasteCommandEvent, } from "./pasteHelpers"; import { + createCommandMenuItem, createSkillMenuItem, type SkillMenuItem, type SkillMetadata, @@ -520,6 +522,12 @@ interface ChatMessageInputProps * detail is still loading (or when no chat exists yet). */ workspaceSkills?: readonly SkillMetadata[]; + /** + * Built-in commands offered by the "/" trigger menu ahead of + * skills. Selection inserts the command text; the parent + * composer intercepts it at submit time. + */ + slashCommands?: readonly ChatSlashCommand[]; "aria-label"?: string; } @@ -588,6 +596,7 @@ const ChatMessageInput = ({ hasWorkspace, personalSkillsOverride, workspaceSkills, + slashCommands, "aria-label": ariaLabel, ref, ...props @@ -631,6 +640,24 @@ const ChatMessageInput = ({ // personal triggers qualified (a qualified alias always resolves) and // treat the workspace list as still loading. const workspaceSkillsKnown = !hasWorkspace || workspaceSkills !== undefined; + // A personal or workspace skill with the same name takes + // precedence over a built-in command: the composer's submit + // intercept defers to the skill, so the menu must not advertise a + // dead command entry. Until both skill lists resolve, a collision + // cannot be ruled out, so no built-in commands are offered + // (matching the submit intercept, which also stands down while + // skills are unknown). + const skillsResolved = + (hasPersonalSkillsOverride || skillsQuery.isSuccess) && + workspaceSkillsKnown; + const availableSlashCommands = skillsResolved + ? (slashCommands ?? []).filter( + (command) => + !personalSkills.some((skill) => skill.name === command.name) && + !loadedWorkspaceSkills.some((skill) => skill.name === command.name), + ) + : []; + const hasSlashCommands = availableSlashCommands.length > 0; // A stale empty cache with a refetch in flight must not dismiss the menu. const isResolvedEmptyPersonalSkills = hasPersonalSkillsOverride ? personalSkills.length === 0 @@ -642,13 +669,18 @@ const ChatMessageInput = ({ // never reopen it. const isResolvedEmptyWorkspaceSkills = workspaceSkillsKnown && loadedWorkspaceSkills.length === 0; - // When both skills lists resolve empty, "/" is plain text. When only - // the filtered result is empty, keep the menu open for the no-match - // message. + // Without built-in commands, "/" is plain text when both skills + // lists resolve empty. When only the filtered result is empty, + // keep the menu open for the no-match message. const skillsMenuOpen = hasSkillsTrigger && - !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills); + (hasSlashCommands || + !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills)); const skillsSearchQuery = skillsTrigger?.query ?? ""; + const commandMenuItems: readonly SkillMenuItem[] = filterSkillsByQuery( + availableSlashCommands.map(createCommandMenuItem), + skillsSearchQuery, + ); const workspaceSkillNames = new Set( loadedWorkspaceSkills.map((skill) => skill.name), ); @@ -668,7 +700,9 @@ const ChatMessageInput = ({ ), skillsSearchQuery, ); + // Commands come first so partitioned menu groups match selection order. const allFilteredSkills: readonly SkillMenuItem[] = [ + ...commandMenuItems, ...personalSkillItems, ...workspaceSkillItems, ]; @@ -957,6 +991,7 @@ const ChatMessageInput = ({ open={skillsMenuOpen} anchorRect={skillsTrigger?.anchorRect ?? null} query={skillsSearchQuery} + commands={commandMenuItems} personalSkills={personalSkillItems} workspaceSkills={workspaceSkillItems} workspaceSkillsEnabled={Boolean(hasWorkspace)} diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx index c3e2bbf52f0..7d0958efae4 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx @@ -1,7 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent } from "storybook/test"; import { filterSkillsByQuery } from "../../utils/personalSkills"; +import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands"; import { + createCommandMenuItem, createSkillMenuItem, type SkillMetadata, SkillsTriggerMenu, @@ -26,6 +28,7 @@ const mockWorkspaceSkills: SkillMetadata[] = [ const mockPersonalSkillItems = MockSkills.map((skill) => createSkillMenuItem("personal", skill), ); +const compactCommandItem = createCommandMenuItem(COMPACT_SLASH_COMMAND); const mockWorkspaceSkillItems = mockWorkspaceSkills.map((skill) => createSkillMenuItem("workspace", skill), ); @@ -153,3 +156,47 @@ export const SelectsByClick: Story = { expect(args.onSelect).toHaveBeenCalledWith(mockPersonalSkillItems[0]); }, }; + +// Built-in commands render in a separate "Commands" group above +// personal skills and stay selectable alongside them. +export const WithCommands: Story = { + args: { + commands: [compactCommandItem], + }, + play: async () => { + expect(await findVisibleText("Commands")).toBeDefined(); + expect(await findVisibleText("/compact")).toBeDefined(); + expect(await findVisibleText("Personal skills")).toBeDefined(); + expect(await findVisibleText("/reviewer")).toBeDefined(); + }, +}; + +// With no skills configured, the menu still opens to offer the +// built-in commands without any skills group or empty message. +export const CommandsOnly: Story = { + args: { + commands: [compactCommandItem], + personalSkills: [], + }, + play: async () => { + expect(await findVisibleText("/compact")).toBeDefined(); + expect( + await findVisibleText( + "Summarize the conversation so far to free up context window space", + ), + ).toBeDefined(); + await expectNoVisibleText("No personal skills found."); + }, +}; + +export const SelectsCommandByClick: Story = { + args: { + commands: [compactCommandItem], + onSelect: fn(), + }, + play: async ({ args }) => { + await userEvent.click(await findVisibleText("/compact")); + expect(args.onSelect).toHaveBeenCalledTimes(1); + expect(args.onSelect).toHaveBeenCalledWith(compactCommandItem); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index 362b1537c5d..7340d3743e0 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -30,7 +30,7 @@ export type SkillMetadata = { }; export type SkillMenuItem = SkillMetadata & { - source: SkillSource; + source: SkillSource | "command"; triggerText: string; // The qualified alias stays searchable even when the displayed // trigger is bare, so a typed qualified query keeps matching after @@ -38,6 +38,19 @@ export type SkillMenuItem = SkillMetadata & { altTriggerText: string; }; +// Built-in commands (e.g. /compact) share the menu item shape so the +// combined keyboard-selection list and trigger replacement work +// unchanged; their trigger text is never source-qualified. +export const createCommandMenuItem = ( + command: SkillMetadata, +): SkillMenuItem => ({ + name: command.name, + description: command.description, + source: "command", + triggerText: `/${command.name}`, + altTriggerText: `/${command.name}`, +}); + export const createSkillMenuItem = ( source: SkillSource, skill: SkillMetadata, @@ -56,6 +69,7 @@ type SkillsTriggerMenuProps = { open: boolean; anchorRect: CaretAnchorRect | null; query: string; + commands?: readonly SkillMenuItem[]; personalSkills: readonly SkillMenuItem[]; workspaceSkills: readonly SkillMenuItem[]; workspaceSkillsEnabled?: boolean; @@ -120,6 +134,7 @@ export const SkillsTriggerMenu = ({ open, anchorRect, query, + commands = [], personalSkills, workspaceSkills, workspaceSkillsEnabled, @@ -131,7 +146,7 @@ export const SkillsTriggerMenu = ({ onSelect, onClose, }: SkillsTriggerMenuProps) => { - const allSkills = [...personalSkills, ...workspaceSkills]; + const allSkills = [...commands, ...personalSkills, ...workspaceSkills]; const statusItems = [ isPersonalLoading && personalSkills.length === 0 ? "Loading personal skills..." @@ -215,17 +230,25 @@ export const SkillsTriggerMenu = ({ value={selectedValue} > + {commands.length > 0 && ( + + {commands.map((skill, index) => renderSkill(skill, index))} + + )} {personalSkills.length > 0 && ( {personalSkills.map((skill, index) => - renderSkill(skill, index), + renderSkill(skill, commands.length + index), )} )} {workspaceSkills.length > 0 && ( {workspaceSkills.map((skill, index) => - renderSkill(skill, personalSkills.length + index), + renderSkill( + skill, + commands.length + personalSkills.length + index, + ), )} )} diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index bf35260abf5..182bbcdaa38 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -11,6 +11,7 @@ import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useFileAttachments } from "../hooks/useFileAttachments"; import { getChatFileURL } from "../utils/chatAttachments"; import { getProviderForModelOption } from "../utils/modelOptions"; +import { CHAT_SLASH_COMMANDS } from "../utils/slashCommands"; import type { ChatDetailError } from "../utils/usageLimitMessage"; import { AgentChatInput, @@ -565,6 +566,12 @@ export const ChatPageInput: FC = ({ modelCount={modelCount} unsupportedProviderNames={unsupportedProviderNames} aiGatewayDisabled={aiGatewayDisabled} + // Commands act on the whole chat, so they only make sense + // for new sends: hide them while editing a history or + // queued message. + slashCommands={ + isEditing || isEditingHistoryMessage ? undefined : CHAT_SLASH_COMMANDS + } /> ); diff --git a/site/src/pages/AgentsPage/utils/slashCommands.ts b/site/src/pages/AgentsPage/utils/slashCommands.ts new file mode 100644 index 00000000000..63bc400d43a --- /dev/null +++ b/site/src/pages/AgentsPage/utils/slashCommands.ts @@ -0,0 +1,28 @@ +/** + * A built-in chat command offered by the "/" trigger menu. Unlike + * personal skills, commands are fixed client-side actions: the + * composer intercepts them at submit time instead of sending the + * text as a message. + */ +export type ChatSlashCommand = { + name: string; + description: string; +}; + +export const COMPACT_SLASH_COMMAND: ChatSlashCommand = { + name: "compact", + description: + "Summarize the conversation so far to free up context window space", +}; + +/** + * Commands available in the main chat composer. Editing an existing + * message and the new-agent form do not offer commands. + */ +export const CHAT_SLASH_COMMANDS: readonly ChatSlashCommand[] = [ + COMPACT_SLASH_COMMAND, +]; + +export const chatSlashCommandTriggerText = ( + command: ChatSlashCommand, +): string => `/${command.name}`; From a9a949081a6f4f56dd61fd53122fd2183bcb89bb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:52:49 +0000 Subject: [PATCH 2/6] fix(site/src/pages/AgentsPage): reset context usage indicator after manual compaction --- .../ChatConversation/chatHelpers.test.ts | 44 +++++++++++-------- .../ChatConversation/chatHelpers.ts | 12 ++++- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.test.ts index d6a88b30d43..d0dc4662a4c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.test.ts @@ -92,35 +92,43 @@ describe("extractContextUsageFromMessage", () => { // --------------------------------------------------------------------------- describe("getLatestContextUsage", () => { - it("returns null for an empty message list", () => { - expect(getLatestContextUsage([])).toBeNull(); - }); - - it("returns null when no messages have usage data", () => { - const messages = [MockChatMessage, { ...MockChatMessage, id: 2 }]; - expect(getLatestContextUsage(messages)).toBeNull(); - }); - - it("returns usage from the last message with usage data", () => { + const compactionSummaryMessage: TypesGen.ChatMessage = { + ...MockChatMessage, + id: 2, + role: "tool", + content: [{ type: "tool-result", tool_name: "chat_summarized" }], + }; + + it("returns usage from the newest usage-bearing message", () => { const messages = [ { ...MockChatMessage, id: 1, usage: { input_tokens: 100 } }, { ...MockChatMessage, id: 2 }, { ...MockChatMessage, id: 3, usage: { input_tokens: 300 } }, ]; const result = getLatestContextUsage(messages); - expect(result).not.toBeNull(); - expect(result!.inputTokens).toBe(300); + expect(result?.inputTokens).toBe(300); }); - it("skips trailing messages without usage and finds the latest one", () => { + it("returns null when a compaction summary is newer than usage", () => { const messages = [ - { ...MockChatMessage, id: 1, usage: { input_tokens: 50 } }, - { ...MockChatMessage, id: 2, usage: { input_tokens: 200 } }, - { ...MockChatMessage, id: 3 }, + { ...MockChatMessage, id: 1, usage: { input_tokens: 100 } }, + compactionSummaryMessage, + ]; + expect(getLatestContextUsage(messages)).toBeNull(); + }); + + it("returns null when no messages have usage data", () => { + const messages = [MockChatMessage, { ...MockChatMessage, id: 2 }]; + expect(getLatestContextUsage(messages)).toBeNull(); + }); + + it("returns usage when it is newer than a compaction summary", () => { + const messages = [ + compactionSummaryMessage, + { ...MockChatMessage, id: 3, usage: { input_tokens: 300 } }, ]; const result = getLatestContextUsage(messages); - expect(result).not.toBeNull(); - expect(result!.inputTokens).toBe(200); + expect(result?.inputTokens).toBe(300); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.ts index a03af4ac31e..d955eacaa25 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatHelpers.ts @@ -47,7 +47,17 @@ export const getLatestContextUsage = ( messages: readonly TypesGen.ChatMessage[], ): AgentContextUsage | null => { for (let index = messages.length - 1; index >= 0; index -= 1) { - const usage = extractContextUsageFromMessage(messages[index]); + const message = messages[index]; + const isCompactionSummary = message.content?.some( + (part) => + (part.type === "tool-call" || part.type === "tool-result") && + part.tool_name === "chat_summarized", + ); + if (isCompactionSummary) { + return null; + } + + const usage = extractContextUsageFromMessage(message); if (usage) { return usage; } From b2cd135412eb88c039eccac5802ed7ecfdebfd16 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:05:49 +0000 Subject: [PATCH 3/6] fix: address manual compaction review findings --- coderd/x/chatd/chatd.go | 14 +-- coderd/x/chatd/chatd_test.go | 100 ++++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 37 ++++--- .../AgentsPage/utils/slashCommands.test.ts | 43 ++++++++ .../pages/AgentsPage/utils/slashCommands.ts | 17 +++ 5 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 site/src/pages/AgentsPage/utils/slashCommands.test.ts diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9e70625262d..d594af63e0c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2159,13 +2159,8 @@ func (p *Server) CompactChat( if lockedChat.Archived { return ErrChatArchived } - // Compaction triggers LLM inference billed to the owner, so - // enforce usage limits like message sends do. - if limitErr := p.checkUsageLimit(ctx, store, lockedChat.OwnerID, uuid.NullUUID{UUID: lockedChat.OrganizationID, Valid: true}); limitErr != nil { - return limitErr - } - // Run the transition first so busy chats surface the state - // conflict rather than a misleading nothing-to-compact. + // Run the transition before content and usage validation so busy + // chats surface the state conflict first. result, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) if err != nil { return err @@ -2185,6 +2180,11 @@ func (p *Server) CompactChat( if _, ok := firstUncompressedAssistantAfter(messages, boundary); !ok { return ErrNothingToCompact } + // Usage validation runs last so rejected requests report the more + // specific state or content conflict. Its failure rolls back the marker. + if limitErr := p.checkUsageLimit(ctx, store, lockedChat.OwnerID, uuid.NullUUID{UUID: lockedChat.OrganizationID, Valid: true}); limitErr != nil { + return limitErr + } refreshed = result.Chat return nil }) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 7242a1ea69b..4d7d31629b3 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -5660,6 +5660,106 @@ func TestActiveServer_Compaction(t *testing.T) { }) } +func TestCompactChat(t *testing.T) { + t.Parallel() + + setupAtLimitChat := func( + t *testing.T, + status database.ChatStatus, + compactable bool, + ) (context.Context, database.Store, *chatd.Server, database.Chat) { + t.Helper() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + server := newTestServer(t, db, ps, uuid.New()) + + _, err := db.UpsertChatUsageLimitConfig(ctx, database.UpsertChatUsageLimitConfigParams{ + Enabled: true, + DefaultLimitMicros: 100, + Period: string(codersdk.ChatUsageLimitPeriodDay), + }) + require.NoError(t, err) + + spendChat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + }) + spendContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("spent usage"), + }) + require.NoError(t, err) + dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: spendChat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + ContentVersion: chatprompt.CurrentContentVersion, + Content: spendContent, + TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true}, + }) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + Status: status, + }) + if compactable { + assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + codersdk.ChatMessageText("assistant response"), + }) + require.NoError(t, err) + dbgen.ChatMessage(t, db, database.ChatMessage{ + ChatID: chat.ID, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + Role: database.ChatMessageRoleAssistant, + ContentVersion: chatprompt.CurrentContentVersion, + Content: assistantContent, + }) + } + + return ctx, db, server, chat + } + + t.Run("usage limit rejects compactable idle chat", func(t *testing.T) { + t.Parallel() + + ctx, db, server, chat := setupAtLimitChat(t, database.ChatStatusWaiting, true) + _, err := server.CompactChat(ctx, chat) + + var limitErr *chatd.UsageLimitExceededError + require.ErrorAs(t, err, &limitErr) + refreshed, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, database.ChatStatusWaiting, refreshed.Status) + require.False(t, refreshed.CompactionRequestedAt.Valid) + }) + + t.Run("nothing to compact precedes usage limit", func(t *testing.T) { + t.Parallel() + + ctx, _, server, chat := setupAtLimitChat(t, database.ChatStatusWaiting, false) + _, err := server.CompactChat(ctx, chat) + + require.ErrorIs(t, err, chatd.ErrNothingToCompact) + var limitErr *chatd.UsageLimitExceededError + require.False(t, errors.As(err, &limitErr)) + }) + + t.Run("state conflict precedes usage limit", func(t *testing.T) { + t.Parallel() + + ctx, _, server, chat := setupAtLimitChat(t, database.ChatStatusRunning, true) + _, err := server.CompactChat(ctx, chat) + + require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed) + var limitErr *chatd.UsageLimitExceededError + require.False(t, errors.As(err, &limitErr)) + }) +} + func TestActiveServer_ManualCompaction(t *testing.T) { t.Parallel() diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4e38bb22e82..f3b1934eeb9 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -110,6 +110,7 @@ import { pickReasoningEffort } from "./utils/reasoningEffort"; import { COMPACT_SLASH_COMMAND, chatSlashCommandTriggerText, + resolveChatSlashCommandAvailability, } from "./utils/slashCommands"; import { type ChatDetailError, @@ -121,6 +122,8 @@ import { export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; +class CompactCommandPendingError extends Error {} + /** @internal Exported for testing. */ export const draftInputStorageKeyPrefix = "agents.draft-input."; @@ -548,6 +551,9 @@ export function useConversationEditingState(deps: { try { await sendPromise; } catch (error) { + if (error instanceof CompactCommandPendingError) { + return; + } rollback?.(); throw error; } @@ -1024,24 +1030,18 @@ const AgentChatPage: FC = () => { const { isPending: isCompactPending, mutateAsync: compact } = useMutation( compactChat(queryClient, agentId ?? ""), ); - // A personal or workspace skill named "compact" must keep working - // as a skill trigger (read_skill resolves a bare /compact to it), - // so the built-in /compact command yields to both. Shares the - // composer trigger menu's query cache. Until the personal skills - // query succeeds and the chat detail resolves workspace skills, a - // conflict cannot be ruled out, so "/compact" is sent as a regular - // message instead of being intercepted. + // A skill named "compact" takes precedence over built-in /compact. Until + // both skill sources resolve, exact submissions wait to avoid shadowing it. const personalSkillsQuery = useQuery({ ...userSkills(), staleTime: 60_000, }); const chatWorkspaceSkills = workspaceSkillsFromChat(chatQuery.data); - const compactCommandAvailable = - personalSkillsQuery.isSuccess && - chatWorkspaceSkills !== undefined && - ![...personalSkillsQuery.data, ...chatWorkspaceSkills].some( - (skill) => skill.name === COMPACT_SLASH_COMMAND.name, - ); + const compactCommandResolution = resolveChatSlashCommandAvailability( + COMPACT_SLASH_COMMAND, + personalSkillsQuery.isSuccess ? personalSkillsQuery.data : undefined, + chatWorkspaceSkills, + ); const { mutateAsync: deleteQueuedMessage } = useMutation( deleteChatQueuedMessage(queryClient, agentId ?? ""), ); @@ -1501,14 +1501,19 @@ const AgentChatPage: FC = () => { // message. Only new sends are intercepted; edits keep their // original meaning, and a personal or workspace skill named // "compact" takes precedence so the command cannot shadow it. - const isCompactCommand = + const isExactCompactSubmission = editedMessageID === undefined && - compactCommandAvailable && content.length === 1 && content[0].type === "text" && content[0].text?.trim() === chatSlashCommandTriggerText(COMPACT_SLASH_COMMAND); - if (isCompactCommand) { + if (isExactCompactSubmission && compactCommandResolution === "pending") { + toast.info( + "Checking whether /compact is available. Try again in a moment.", + ); + throw new CompactCommandPendingError(); + } + if (isExactCompactSubmission && compactCommandResolution === "available") { // Optimistically show the running state before awaiting so // a fast compaction cannot race this write: the worker's // authoritative waiting status may arrive over the stream diff --git a/site/src/pages/AgentsPage/utils/slashCommands.test.ts b/site/src/pages/AgentsPage/utils/slashCommands.test.ts new file mode 100644 index 00000000000..b3e265e62a3 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/slashCommands.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + COMPACT_SLASH_COMMAND, + resolveChatSlashCommandAvailability, +} from "./slashCommands"; + +describe("resolveChatSlashCommandAvailability", () => { + it("stays pending until both skill sources resolve", () => { + expect( + resolveChatSlashCommandAvailability(COMPACT_SLASH_COMMAND, undefined, []), + ).toBe("pending"); + expect( + resolveChatSlashCommandAvailability(COMPACT_SLASH_COMMAND, [], undefined), + ).toBe("pending"); + }); + + it("is unavailable when either skill source defines the command", () => { + expect( + resolveChatSlashCommandAvailability( + COMPACT_SLASH_COMMAND, + [{ name: "compact" }], + [], + ), + ).toBe("unavailable"); + expect( + resolveChatSlashCommandAvailability( + COMPACT_SLASH_COMMAND, + [], + [{ name: "compact" }], + ), + ).toBe("unavailable"); + }); + + it("is available when both skill sources resolve without a collision", () => { + expect( + resolveChatSlashCommandAvailability( + COMPACT_SLASH_COMMAND, + [{ name: "review" }], + [{ name: "test" }], + ), + ).toBe("available"); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/slashCommands.ts b/site/src/pages/AgentsPage/utils/slashCommands.ts index 63bc400d43a..f6d5da97e1a 100644 --- a/site/src/pages/AgentsPage/utils/slashCommands.ts +++ b/site/src/pages/AgentsPage/utils/slashCommands.ts @@ -23,6 +23,23 @@ export const CHAT_SLASH_COMMANDS: readonly ChatSlashCommand[] = [ COMPACT_SLASH_COMMAND, ]; +type ChatSlashCommandResolution = "pending" | "available" | "unavailable"; + +export const resolveChatSlashCommandAvailability = ( + command: ChatSlashCommand, + personalSkills: readonly { name: string }[] | undefined, + workspaceSkills: readonly { name: string }[] | undefined, +): ChatSlashCommandResolution => { + if (personalSkills === undefined || workspaceSkills === undefined) { + return "pending"; + } + return [...personalSkills, ...workspaceSkills].some( + (skill) => skill.name === command.name, + ) + ? "unavailable" + : "available"; +}; + export const chatSlashCommandTriggerText = ( command: ChatSlashCommand, ): string => `/${command.name}`; From 9004678a6a54d53b6c7799ec72ec88e7f4f1d01b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:27:54 +0000 Subject: [PATCH 4/6] fix(coderd/x/chatd): hand off ownership for manual compaction --- .../chatstate/request_compaction_test.go | 29 +++++++++++++++++-- coderd/x/chatd/chatstate/transitions.go | 13 ++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/coderd/x/chatd/chatstate/request_compaction_test.go b/coderd/x/chatd/chatstate/request_compaction_test.go index a344888d33f..5599ac79baa 100644 --- a/coderd/x/chatd/chatstate/request_compaction_test.go +++ b/coderd/x/chatd/chatstate/request_compaction_test.go @@ -21,14 +21,27 @@ import ( // (ownership changes, queue appends) and the intended consumers // (compaction commit, turn-terminal transitions). -// requestCompaction seeds an idle chat and records a manual -// compaction request, returning the machine for follow-up -// transitions. func requestCompaction(t *testing.T, f *testFixture) (uuid.UUID, *chatstate.ChatMachine) { t.Helper() ctx := testutil.Context(t, testutil.WaitShort) seeded := seedState(t, f, chatstate.StateW) m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID) + + worker := uuid.New() + runner := uuid.New() + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { + _, err := tx.Acquire(chatstate.AcquireInput{WorkerID: worker, RunnerID: runner}) + return err + })) + stale, err := f.DB.IsChatHeartbeatStale(ctx, database.IsChatHeartbeatStaleParams{ + ChatID: seeded.chatID, + RunnerID: runner, + StaleSeconds: chatstate.HeartbeatStaleSeconds, + }) + require.NoError(t, err) + require.False(t, stale, "owned runner heartbeat must be fresh") + ownershipBefore := f.Pub.ownershipPublishCount() + require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error { _, err := tx.RequestCompaction(chatstate.RequestCompactionInput{}) return err @@ -36,9 +49,19 @@ func requestCompaction(t *testing.T, f *testFixture) (uuid.UUID, *chatstate.Chat chat := f.readChat(ctx, t, seeded.chatID) require.True(t, chat.CompactionRequestedAt.Valid, "request must set the marker") require.Equal(t, database.ChatStatusRunning, chat.Status) + require.False(t, chat.WorkerID.Valid, "request must clear worker_id") + require.False(t, chat.RunnerID.Valid, "request must clear runner_id") + require.Equal(t, ownershipBefore+1, f.Pub.ownershipPublishCount(), + "cleared ownership must publish an ownership hint") return seeded.chatID, m } +func TestRequestCompaction_ClearsOwnershipAndPublishesHint(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + requestCompaction(t, f) +} + // TestRequestCompaction_PreservedByAcquireAndQueueAppend verifies the // marker survives worker acquisition and queued message appends: both // happen between the request and the compaction commit in normal diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 4103dedfd22..6b8593eff83 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -633,11 +633,10 @@ type RequestCompactionResult struct { Chat database.Chat } -// RequestCompaction records a manual compaction request on an idle -// chat and moves it to running so a worker picks it up. No message is -// inserted; the request is a one-shot marker consumed by the worker's -// compaction commit and cleared by every transition that starts a new -// turn or leaves running, so a stale request never replays later. +// RequestCompaction records a manual compaction request and hands ownership +// off to a worker. The transition changes no history, so the previous runner +// cannot detect the work from its existing running snapshot. Clearing ownership +// makes ChatMachine.Update publish an ownership hint for worker acquisition. func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResult, error) { chat, _, err := tx.requireFromAllowed(TransitionRequestCompaction) if err != nil { @@ -650,8 +649,8 @@ func (tx *Tx) RequestCompaction(_ RequestCompactionInput) (RequestCompactionResu updated, err := tx.applyExecutionState(executionStateUpdate{ Status: database.ChatStatusRunning, Archived: false, - WorkerID: chat.WorkerID, - RunnerID: chat.RunnerID, + WorkerID: uuid.NullUUID{}, + RunnerID: uuid.NullUUID{}, LastError: chat.LastError, RequiresActionDeadlineAt: sql.NullTime{}, CompactionRequestedAt: sql.NullTime{Time: now, Valid: true}, From ed597232b13412a762b3c845dd2f874f9acd2c51 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:43:21 +0000 Subject: [PATCH 5/6] fix: skip public API docs for experimental compact endpoint --- coderd/apidoc/docs.go | 5 +- coderd/apidoc/swagger.json | 5 +- coderd/exp_chats.go | 1 + docs/reference/api/chats.md | 234 ------------------------------------ 4 files changed, 9 insertions(+), 236 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5222ebf885e..a828251575f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -538,7 +538,10 @@ const docTemplate = `{ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, "/api/experimental/chats/{chat}/context": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7bedd801cfd..ca44baeb9ce 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -471,7 +471,10 @@ { "CoderSessionToken": [] } - ] + ], + "x-apidocgen": { + "skip": true + } } }, "/api/experimental/chats/{chat}/context": { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 74a0b67533f..aa95221609c 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3984,6 +3984,7 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Success 200 {object} codersdk.Chat // @Router /api/experimental/chats/{chat}/compact [post] +// @x-apidocgen {"skip": true} // @Description Experimental: this endpoint is subject to change. // @Description Requests a manual context compaction on an idle chat. The // @Description compaction runs asynchronously through the chat worker and diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 2e3f2740b47..1b89aeb459d 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -1043,240 +1043,6 @@ Experimental: this endpoint is subject to change. To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Compact chat - -### Code samples - -```sh -# Example request using curl -curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/compact \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`POST /api/experimental/chats/{chat}/compact` - -Experimental: this endpoint is subject to change. -Requests a manual context compaction on an idle chat. The -compaction runs asynchronously through the chat worker and -bypasses the automatic usage threshold. - -### Parameters - -| Name | In | Type | Required | Description | -|--------|------|--------------|----------|-------------| -| `chat` | path | string(uuid) | true | Chat ID | - -### Example responses - -> 200 Response - -```json -{ - "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", - "archived": true, - "build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb", - "children": [ - { - "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", - "archived": true, - "build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb", - "children": [], - "client_type": "ui", - "context": { - "dirty": true, - "dirty_since": "2019-08-24T14:15:22Z", - "error": "string", - "resources": [ - { - "error": "string", - "kind": "instruction_file", - "size_bytes": 0, - "skill_description": "string", - "skill_name": "string", - "source": "string", - "status": "ok", - "tools": [ - { - "description": "string", - "name": "string" - } - ] - } - ] - }, - "created_at": "2019-08-24T14:15:22Z", - "diff_status": { - "additions": 0, - "approved": true, - "author_avatar_url": "string", - "author_login": "string", - "base_branch": "string", - "changed_files": 0, - "changes_requested": true, - "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", - "commits": 0, - "deletions": 0, - "head_branch": "string", - "pr_number": 0, - "pull_request_draft": true, - "pull_request_state": "string", - "pull_request_title": "string", - "refreshed_at": "2019-08-24T14:15:22Z", - "reviewer_count": 0, - "stale_at": "2019-08-24T14:15:22Z", - "url": "string" - }, - "files": [ - { - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "mime_type": "string", - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" - } - ], - "has_unread": true, - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "labels": { - "property1": "string", - "property2": "string" - }, - "last_error": { - "detail": "string", - "kind": "generic", - "message": "string", - "provider": "string", - "retryable": true, - "status_code": 0 - }, - "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", - "last_reasoning_effort": "string", - "last_turn_summary": "string", - "mcp_server_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "owner_name": "string", - "owner_username": "string", - "parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359", - "pin_order": 0, - "plan_mode": "plan", - "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", - "shared": true, - "status": "waiting", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z", - "warnings": [ - "string" - ], - "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" - } - ], - "client_type": "ui", - "context": { - "dirty": true, - "dirty_since": "2019-08-24T14:15:22Z", - "error": "string", - "resources": [ - { - "error": "string", - "kind": "instruction_file", - "size_bytes": 0, - "skill_description": "string", - "skill_name": "string", - "source": "string", - "status": "ok", - "tools": [ - { - "description": "string", - "name": "string" - } - ] - } - ] - }, - "created_at": "2019-08-24T14:15:22Z", - "diff_status": { - "additions": 0, - "approved": true, - "author_avatar_url": "string", - "author_login": "string", - "base_branch": "string", - "changed_files": 0, - "changes_requested": true, - "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", - "commits": 0, - "deletions": 0, - "head_branch": "string", - "pr_number": 0, - "pull_request_draft": true, - "pull_request_state": "string", - "pull_request_title": "string", - "refreshed_at": "2019-08-24T14:15:22Z", - "reviewer_count": 0, - "stale_at": "2019-08-24T14:15:22Z", - "url": "string" - }, - "files": [ - { - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "mime_type": "string", - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05" - } - ], - "has_unread": true, - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "labels": { - "property1": "string", - "property2": "string" - }, - "last_error": { - "detail": "string", - "kind": "generic", - "message": "string", - "provider": "string", - "retryable": true, - "status_code": 0 - }, - "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", - "last_reasoning_effort": "string", - "last_turn_summary": "string", - "mcp_server_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "owner_name": "string", - "owner_username": "string", - "parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359", - "pin_order": 0, - "plan_mode": "plan", - "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", - "shared": true, - "status": "waiting", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z", - "warnings": [ - "string" - ], - "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Refresh chat context ### Code samples From ef8f7c26d687d305b1c7bad24d9e1209852297ac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:02:42 +0000 Subject: [PATCH 6/6] fix(site/src/pages/AgentsPage): keep queued message edits out of compact intercept --- .../AgentsPage/AgentChatPage.stories.tsx | 80 ++++++++++++++++++- site/src/pages/AgentsPage/AgentChatPage.tsx | 7 +- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index f10dba25ab4..0307fe89c3e 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -21,7 +21,10 @@ import { } from "#/api/queries/chats"; import { workspaceByIdKey } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; -import { MockChatMessage } from "#/testHelpers/chatEntities"; +import { + MockChatMessage, + MockChatQueuedMessage, +} from "#/testHelpers/chatEntities"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; import { MockGroup, @@ -2657,6 +2660,26 @@ const compactCommandMessages: TypesGen.ChatMessagesResponse = { has_more: false, }; +const compactQueuedEditChat: TypesGen.Chat = { + id: CHAT_ID, + ...baseChatFields, + title: "Compact queued edit", + status: "running", +}; + +const compactQueuedEditMessages: TypesGen.ChatMessagesResponse = { + messages: compactCommandMessages.messages, + queued_messages: [ + { + ...MockChatQueuedMessage, + id: 3, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Queued follow-up" }], + }, + ], + has_more: false, +}; + /** Submitting "/compact" alone requests a manual compaction instead of * sending a chat message. */ export const SlashCompactCommandSubmits: Story = { @@ -2704,6 +2727,61 @@ export const SlashCompactCommandSubmits: Story = { }, }; +export const SlashCompactQueuedEditSaves: Story = { + parameters: { + queries: buildQueries(compactQueuedEditChat, compactQueuedEditMessages, { + diffUrl: undefined, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getUserSkills").mockResolvedValue([]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const compactSpy = spyOn(API.experimental, "compactChat"); + const sendSpy = spyOn( + API.experimental, + "createChatMessage", + ).mockResolvedValue({ + queued: true, + queued_message: { + ...MockChatQueuedMessage, + id: 4, + chat_id: CHAT_ID, + content: [{ type: "text", text: "/compact" }], + }, + }); + const deleteSpy = spyOn( + API.experimental, + "deleteChatQueuedMessage", + ).mockResolvedValue(); + spyOn(API.experimental, "getChat").mockResolvedValue(compactQueuedEditChat); + spyOn(API.experimental, "getChatMessages").mockResolvedValue({ + ...compactQueuedEditMessages, + queued_messages: [], + }); + + await userEvent.click(await canvas.findByRole("button", { name: "Edit" })); + const editor = await canvas.findByTestId("chat-message-input"); + await userEvent.clear(editor); + await userEvent.type(editor, "/compact"); + await userEvent.click(canvas.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(deleteSpy).toHaveBeenCalledTimes(1); + }); + expect(sendSpy).toHaveBeenCalledWith( + CHAT_ID, + expect.objectContaining({ + content: [{ type: "text", text: "/compact" }], + }), + ); + expect(deleteSpy).toHaveBeenCalledWith(CHAT_ID, 3); + expect(compactSpy).not.toHaveBeenCalled(); + }, +}; + /** A personal skill named "compact" takes precedence: "/compact" is sent * as a normal message (skill trigger) and no compaction is requested. */ export const SlashCompactYieldsToPersonalSkill: Story = { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index f3b1934eeb9..46f9afd0185 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1498,11 +1498,12 @@ const AgentChatPage: FC = () => { // "/compact" on its own (no attachments or file references) // requests a manual context compaction instead of sending a - // message. Only new sends are intercepted; edits keep their - // original meaning, and a personal or workspace skill named - // "compact" takes precedence so the command cannot shadow it. + // message. Only new sends are intercepted; history and queued + // edits keep their original meaning, and a personal or workspace + // skill named "compact" takes precedence so the command cannot shadow it. const isExactCompactSubmission = editedMessageID === undefined && + editing.editingQueuedMessageID === null && content.length === 1 && content[0].type === "text" && content[0].text?.trim() ===