Files
coder/coderd/x/chatd/chatstate/request_compaction_test.go
T
Michael Suchacz 3227cac217 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.
2026-07-21 10:58:08 +02:00

241 lines
8.2 KiB
Go

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).
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
}))
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
// 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)
})
}
}