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:
Michael Suchacz
2026-07-21 10:58:08 +02:00
committed by GitHub
parent ed64eb3515
commit 3227cac217
48 changed files with 2457 additions and 177 deletions
+39 -20
View File
@@ -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