mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add manual chat compaction via /compact (#27081)
Adds a user-triggered `/compact` action for Coder Agents chats: typing
`/compact` in the composer (or picking it from the `/` trigger menu)
summarizes the conversation so far to free up context window space.
## How it works
- New `POST /api/experimental/chats/{chat}/compact` endpoint
(owner-only, RBAC `ActionUpdate`, excluded from the public API reference
via `x-apidocgen skip`). It marks the chat with a durable one-shot
`chats.compaction_requested_at` signal and moves it `waiting -> running`
via a new `RequestCompaction` state transition; no message row is
inserted. AI Gateway attribution needs no per-request key: generation
preparation resolves the owner's synthetic API key (#27170) like any
other turn.
- `RequestCompaction` hands off chat ownership (clears
`worker_id`/`runner_id`) so a worker acquisition hint is published;
since the transition changes no history, the previous runner could
otherwise miss the request under reordered pubsub delivery.
- The background chat worker picks the chat up like any other turn. A
pending manual request takes precedence over turn completion in the
generation decision, and forces compaction even below the automatic
threshold (and when compaction is disabled via threshold=100). The
commit step consumes the request marker in the same transaction; any
transition that ends the turn clears stale markers.
- The summary triplet reuses the automatic-compaction path, now tagged
with a `source` (`automatic` | `manual`) that is plumbed through
streamed progress parts, persisted tool JSON, and the UI label
("Summarized (manual)").
- Validation order: busy chats reject with 409 (state-machine conflict),
empty/already-compacted chats with 409 "nothing to compact", archived
chats with 400; the owner usage-limit check runs last so no-op requests
surface the specific conflict instead of a limit error.
- Web UI: the `/` trigger menu now has a built-in "Commands" group
listing `/compact`; submit intercepts exactly `/compact` and calls the
endpoint instead of sending a message. A personal or workspace skill
named `compact` takes precedence over the built-in command; while skill
collisions are still resolving, an exact `/compact` submission is
blocked with a retryable hint instead of leaking as message text.
History and queued-message edits are never intercepted. After
compaction, the context usage indicator resets to its unknown state
until the next assistant response reports fresh usage, instead of
showing the stale pre-compaction number.
- codersdk: `ExperimentalClient.CompactChat`.
Worker-path execution (rather than compacting synchronously in the
handler) reuses the existing lock fencing, live "Summarizing..."
streaming, retry accounting, restart resilience, and debug-run
observability. Rationale documented in `coderd/x/chatd/ARCHITECTURE.md`.
## Testing
- State machine: transition-matrix coverage for `RequestCompaction`,
marker lifecycle tests (carried by lease renewals/queue appends, cleared
by terminal transitions, consumed by commit), ownership handoff +
acquisition hint assertions.
- Worker: decision-ordering and forced-compaction unit tests;
active-server end-to-end test (manual compact below threshold produces a
`source=manual` summary, returns to `waiting`, no assistant follow-up;
busy chat rejected).
- API: success, archived, non-owner, RBAC-denied, empty-chat, no-daemon
cases; usage-limit ordering (at-limit owners still get
state/nothing-to-compact conflicts for no-op requests, with marker
rollback).
- Frontend: Storybook play tests for the Commands menu group, submit
intercept, skill-name collision, queued-edit passthrough, and
manual/automatic tool rendering; unit tests for command availability
resolution and the post-compaction context usage reset.
> This PR was created by Mux, an AI coding agent, working on Mike's
behalf.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user