Files
coder/coderd/x/chatd/chatstate/transitions_matrix_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

1884 lines
81 KiB
Go

package chatstate_test
import (
"context"
"encoding/json"
"fmt"
"slices"
"sync"
"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/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// Matrix harness: spec types, scenario labels, appliers, case runners,
// and the single entry point that walks the production transition
// matrix to confirm every allowed combination has positive coverage
// and every disallowed combination surfaces the right sentinel error.
// scenario is a typed, semantic label that distinguishes positive
// matrix cases that share the same (transition, from, want) key.
// Empty scenario is fine when no label is needed. The constants
// below enumerate every label used by matrixCases().
type scenario string
const (
// scenarioQueue marks SendMessage cases driven by
// BusyBehaviorQueue.
scenarioQueue scenario = "queue"
// scenarioInterrupt marks SendMessage cases driven by
// BusyBehaviorInterrupt.
scenarioInterrupt scenario = "interrupt"
// scenarioMulti marks cases seeded with multiple queued
// messages so the post-mutation queue stays non-empty.
scenarioMulti scenario = "multi"
// scenarioHeadTarget marks multi-queued PromoteQueuedMessage
// cases that target the queue head. For R1/I1 head-target is
// reorder-only: no rows are updated, so queue order and
// queue_version are unchanged. For E1/A1 head-target still
// pops the head into history.
scenarioHeadTarget scenario = "head_target"
// scenarioNonHead marks multi-queued PromoteQueuedMessage cases
// that target a non-head queued message so the target moves to
// the head and queue_version advances.
scenarioNonHead scenario = "non_head"
// scenarioWithQueue marks ReconcileInvalidState cases seeded
// with a non-empty queue.
scenarioWithQueue scenario = "with_queue"
// scenarioRejectNonDynamicOutstandingToolCall marks the
// FinishInterruption case that exercises the precondition
// rejecting outstanding non-dynamic tool calls.
scenarioRejectNonDynamicOutstandingToolCall scenario = "reject_non_dynamic_outstanding_tool_call"
)
func transitionAllowed(tr chatstate.Transition, from chatstate.ExecutionState) bool {
return slices.Contains(chatstate.AllowedExecutionTransitionsFrom(from), tr)
}
// expectedErrorForDisallowed returns the sentinel chatstate package
// returns when a transition is attempted from a state where the
// matrix forbids it. N (missing chat) becomes ErrChatNotFound;
// Invalid becomes ErrInvalidState (except for ReconcileInvalidState
// which is allowed); everything else becomes ErrTransitionNotAllowed.
func expectedErrorForDisallowed(tr chatstate.Transition, from chatstate.ExecutionState) error {
switch from {
case chatstate.StateN:
if tr == chatstate.TransitionCreateChat {
// CreateChat is not exercised through ChatMachine.Update,
// so this branch is unused in practice. Returning the
// not-allowed sentinel keeps the helper total.
return chatstate.ErrTransitionNotAllowed
}
return chatstate.ErrChatNotFound
case chatstate.StateInvalid:
if tr == chatstate.TransitionReconcileInvalidState {
return nil
}
return chatstate.ErrInvalidState
}
return chatstate.ErrTransitionNotAllowed
}
// Transition appliers
//
// Each transition has one default applier that exercises it with
// inputs derived from the seeded chat. Positive case specs reuse these
// appliers unless a case needs a different input shape (for example,
// SendMessage queue versus interrupt from the same source state).
// The disallowed coverage path also uses these defaults.
func applySetArchived(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, from chatstate.ExecutionState, _ *transitionCaseResult) error {
t.Helper()
// Archived states unarchive, others archive. For disallowed
// states the value does not matter; the transition fails first.
archived := true
switch from {
case chatstate.StateXW, chatstate.StateXE0, chatstate.StateXE1:
archived = false
}
_, err := tx.SetArchived(chatstate.SetArchivedInput{Archived: archived})
return err
}
func applySendMessageQueue(t *testing.T, f *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.sendMessage, err = tx.SendMessage(chatstate.SendMessageInput{
Message: userTextMessage("sm-queue", f.User.ID, f.Model.ID),
BusyBehavior: chatstate.BusyBehaviorQueue,
})
return err
}
func applySendMessageInterrupt(t *testing.T, f *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.sendMessage, err = tx.SendMessage(chatstate.SendMessageInput{
Message: userTextMessage("sm-interrupt", f.User.ID, f.Model.ID),
BusyBehavior: chatstate.BusyBehaviorInterrupt,
})
return err
}
func applyEditMessage(t *testing.T, f *testFixture, tx *chatstate.Tx, seeded seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
content := mustMarshalParts(t, []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited")})
var err error
result.editMessage, err = tx.EditMessage(chatstate.EditMessageInput{
MessageID: seeded.initialUserMessageID,
CreatedBy: f.User.ID,
Content: content,
})
return err
}
func applyDeleteQueuedMessage(t *testing.T, _ *testFixture, tx *chatstate.Tx, seeded seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var targetQueueID int64
if len(seeded.queuedMessageIDs) > 0 {
targetQueueID = seeded.queuedMessageIDs[0]
}
var err error
result.deleteQueuedMessage, err = tx.DeleteQueuedMessage(chatstate.DeleteQueuedMessageInput{
QueuedMessageID: targetQueueID,
})
return err
}
func applyPromoteQueuedMessage(t *testing.T, _ *testFixture, tx *chatstate.Tx, seeded seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var targetQueueID int64
if len(seeded.queuedMessageIDs) > 0 {
targetQueueID = seeded.queuedMessageIDs[0]
}
var err error
result.promoteQueuedMessage, err = tx.PromoteQueuedMessage(chatstate.PromoteQueuedMessageInput{
QueuedMessageID: targetQueueID,
})
return err
}
func applyInterrupt(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.interrupt, err = tx.Interrupt(chatstate.InterruptInput{Reason: "test"})
return err
}
func applyCompleteRequiresAction(t *testing.T, f *testFixture, tx *chatstate.Tx, seeded seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var results []chatstate.ToolResultInput
if seeded.pendingToolCallID != "" {
results = []chatstate.ToolResultInput{{
ToolCallID: seeded.pendingToolCallID,
Output: json.RawMessage(`{"ok":true}`),
IsError: false,
}}
}
var err error
result.completeRequiresAction, err = tx.CompleteRequiresAction(chatstate.CompleteRequiresActionInput{
CreatedBy: f.User.ID,
ModelConfigID: f.Model.ID,
Results: results,
})
return err
}
func applyRecordGenerationAttempt(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.recordGenerationAttempt, err = tx.RecordGenerationAttempt(chatstate.RecordGenerationAttemptInput{})
return err
}
func applyRecordRetryState(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.recordRetryState, err = tx.RecordRetryState(chatstate.RecordRetryStateInput{
RetryState: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"attempt":1,"delay_ms":250,"error":"retry","retrying_at":"2026-05-29T00:00:00Z"}`),
Valid: true,
},
})
return err
}
func applyCommitStep(t *testing.T, f *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
assistant := userTextMessage("assistant", f.User.ID, f.Model.ID)
assistant.Role = database.ChatMessageRoleAssistant
var err error
result.commitStep, err = tx.CommitStep(chatstate.CommitStepInput{
Messages: []chatstate.Message{assistant},
})
return err
}
func applyEnterRequiresAction(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.enterRequiresAction, err = tx.EnterRequiresAction(chatstate.EnterRequiresActionInput{})
return err
}
func applyFinishInterruption(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.finishInterruption, err = tx.FinishInterruption(chatstate.FinishInterruptionInput{})
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
result.finishTurn, err = tx.FinishTurn(chatstate.FinishTurnInput{})
return err
}
func applyFinishError(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.finishError, err = tx.FinishError(chatstate.FinishErrorInput{
LastError: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"message":"finish-error"}`),
Valid: true,
},
})
return err
}
func applyCancelRequiresAction(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.cancelRequiresAction, err = tx.CancelRequiresAction(chatstate.CancelRequiresActionInput{
Reason: "cancel from test",
})
return err
}
func applyReconcileInvalidState(t *testing.T, _ *testFixture, tx *chatstate.Tx, _ seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
var err error
result.reconcileInvalidState, err = tx.ReconcileInvalidState(chatstate.ReconcileInvalidStateInput{})
return err
}
// defaultApplier returns the canonical applier for tr. Used by the
// disallowed coverage path where the input shape does not matter
// because the transition fails before the inputs are consumed.
func defaultApplier(tr chatstate.Transition) applierFn {
switch tr {
case chatstate.TransitionSetArchived:
return applySetArchived
case chatstate.TransitionSendMessage:
return applySendMessageQueue
case chatstate.TransitionEditMessage:
return applyEditMessage
case chatstate.TransitionRequestCompaction:
return applyRequestCompaction
case chatstate.TransitionDeleteQueuedMessage:
return applyDeleteQueuedMessage
case chatstate.TransitionPromoteQueuedMessage:
return applyPromoteQueuedMessage
case chatstate.TransitionInterrupt:
return applyInterrupt
case chatstate.TransitionCompleteRequiresAction:
return applyCompleteRequiresAction
case chatstate.TransitionRecordGenerationAttempt:
return applyRecordGenerationAttempt
case chatstate.TransitionRecordRetryState:
return applyRecordRetryState
case chatstate.TransitionCommitStep:
return applyCommitStep
case chatstate.TransitionEnterRequiresAction:
return applyEnterRequiresAction
case chatstate.TransitionFinishInterruption:
return applyFinishInterruption
case chatstate.TransitionFinishTurn:
return applyFinishTurn
case chatstate.TransitionFinishError:
return applyFinishError
case chatstate.TransitionCancelRequiresAction:
return applyCancelRequiresAction
case chatstate.TransitionReconcileInvalidState:
return applyReconcileInvalidState
}
return nil
}
// mustMarshalParts is a tiny test helper that fails the test on
// marshal error rather than forcing every call site to handle it.
func mustMarshalParts(t *testing.T, parts []codersdk.ChatMessagePart) pqtype.NullRawMessage {
t.Helper()
raw, err := chatprompt.MarshalParts(parts)
require.NoError(t, err)
return raw
}
// Case-level transition matrix spec.
//
// Each entry in matrixCases is one positive (transition, from, want)
// triple. The coverage key is (transition, from, want); scenario is a
// readability-and-semantic suffix for the subtest name that
// distinguishes multiple cases sharing the same coverage key.
// Disallowed combinations are enumerated separately from
// AllowedExecutionTransitionsFrom and AllowedExecutionTransitionOutputs.
type transitionCaseResult struct {
sendMessage chatstate.SendMessageResult
editMessage chatstate.EditMessageResult
requestCompaction chatstate.RequestCompactionResult
deleteQueuedMessage chatstate.DeleteQueuedMessageResult
promoteQueuedMessage chatstate.PromoteQueuedMessageResult
interrupt chatstate.InterruptResult
completeRequiresAction chatstate.CompleteRequiresActionResult
recordGenerationAttempt chatstate.RecordGenerationAttemptResult
recordRetryState chatstate.RecordRetryStateResult
commitStep chatstate.CommitStepResult
enterRequiresAction chatstate.EnterRequiresActionResult
finishInterruption chatstate.FinishInterruptionResult
finishTurn chatstate.FinishTurnResult
finishError chatstate.FinishErrorResult
cancelRequiresAction chatstate.CancelRequiresActionResult
reconcileInvalidState chatstate.ReconcileInvalidStateResult
}
type applierFn func(t *testing.T, f *testFixture, tx *chatstate.Tx, seeded seededChat, from chatstate.ExecutionState, result *transitionCaseResult) error
type assertFn func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult)
// seederFn produces a seededChat for a case. Cases that omit a custom
// seeder use seedState by default. Custom seeders are required when
// the case needs more than one queued message, an Invalid chat with a
// non-empty queue, or a transition that needs a fresh A0/A1 seed.
type seederFn func(t *testing.T, f *testFixture, from chatstate.ExecutionState) seededChat
type transitionCaseSpec struct {
transition chatstate.Transition
from chatstate.ExecutionState
want chatstate.ExecutionState
// scenario is a semantic label appended to the subtest name
// when the same (transition, from, want) key needs to run more
// than once. It is not part of the coverage key but is part of
// the duplicate-detection key.
scenario scenario
seed seederFn
apply applierFn
assert assertFn
assertFailure func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, err error)
}
// caseKey is the unit of coverage for positive cases. scenario is
// intentionally not part of the key so cases with different scenarios
// can still satisfy the same coverage cell.
type caseKey struct {
transition chatstate.Transition
from chatstate.ExecutionState
want chatstate.ExecutionState
}
// fullCaseKey extends caseKey with scenario. Used for duplicate
// detection: two cases must not share the same full key.
type fullCaseKey struct {
transition chatstate.Transition
from chatstate.ExecutionState
want chatstate.ExecutionState
scenario scenario
}
// queueShape selects the seed variant for transition case builders.
// A typed enum is used instead of a bool to avoid the revive
// flag-parameter rule and to make call sites self-documenting.
type queueShape int
const (
// queueShapeDefault routes through seedState, which produces the
// canonical single-queued seed for queue-bearing states and the
// empty queue for non-queue states.
queueShapeDefault queueShape = iota
// queueShapeMulti routes through seedStateMultiQueued (or
// seedInvalidWithQueue for ReconcileInvalidState) so the
// post-mutation queue can remain non-empty.
queueShapeMulti
)
func (s queueShape) isMulti() bool { return s == queueShapeMulti }
func (s transitionCaseSpec) key() caseKey {
return caseKey{transition: s.transition, from: s.from, want: s.want}
}
func (s transitionCaseSpec) fullKey() fullCaseKey {
return fullCaseKey{
transition: s.transition,
from: s.from,
want: s.want,
scenario: s.scenario,
}
}
func (s transitionCaseSpec) subtestName() string {
name := fmt.Sprintf("%s/%s_to_%s", s.transition, s.from, s.want)
if s.scenario != "" {
name += "/" + string(s.scenario)
}
return name
}
// disallowedCaseKey is the unit of coverage for negative cases.
type disallowedCaseKey struct {
transition chatstate.Transition
from chatstate.ExecutionState
}
// remainingExcluding returns ids with the entry at exclude removed.
// The order of the surviving entries is preserved.
func remainingExcluding(ids []int64, exclude int) []int64 {
out := make([]int64, 0, len(ids))
for i, id := range ids {
if i == exclude {
continue
}
out = append(out, id)
}
return out
}
// remainingBodiesExcluding returns bodies with the entry at exclude
// removed. The order of the surviving entries is preserved.
func remainingBodiesExcluding(bodies []string, exclude int) []string {
out := make([]string, 0, len(bodies))
for i, b := range bodies {
if i == exclude {
continue
}
out = append(out, b)
}
return out
}
// Test runner
// runPositiveCase seeds the chat, runs the transition, and asserts the
// post-state plus case-specific effects.
func runPositiveCase(t *testing.T, spec transitionCaseSpec) {
t.Helper()
require.NotNil(t, spec.apply, "case %s missing apply", spec.subtestName())
f := newTestFixture(t)
ctx := testutil.Context(t, testutil.WaitShort)
seeder := spec.seed
if seeder == nil {
seeder = seedState
}
seeded := seeder(t, f, spec.from)
if seeded.exists {
require.Equal(t, spec.from, f.classify(ctx, t, seeded.chatID),
"seed must land in %s", spec.from)
}
base := captureBaseline(ctx, t, f, seeded)
m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID)
var result transitionCaseResult
err := m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
return spec.apply(t, f, tx, seeded, spec.from, &result)
})
if spec.assertFailure != nil {
spec.assertFailure(ctx, t, f, seeded, base, err)
return
}
require.NoError(t, err, "%s from %s must succeed", spec.transition, spec.from)
assertSnapshotBumpedOnce(ctx, t, f, seeded.chatID, base)
require.Equal(t, spec.want, f.classify(ctx, t, seeded.chatID),
"%s: %s -> %s", spec.transition, spec.from, spec.want)
if spec.assert != nil {
spec.assert(ctx, t, f, seeded, base, result)
}
}
// runDisallowedCase seeds the chat, runs the transition with default
// inputs, and asserts that the chatstate package surfaces the right
// sentinel error and rolled the snapshot bump back.
func runDisallowedCase(t *testing.T, tr chatstate.Transition, from chatstate.ExecutionState) {
t.Helper()
f := newTestFixture(t)
ctx := testutil.Context(t, testutil.WaitShort)
seeded := seedState(t, f, from)
if seeded.exists {
require.Equal(t, from, f.classify(ctx, t, seeded.chatID),
"disallowed seed must land in %s", from)
}
base := captureBaseline(ctx, t, f, seeded)
applier := defaultApplier(tr)
require.NotNil(t, applier, "no default applier for transition %s", tr)
m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID)
var result transitionCaseResult
err := m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
return applier(t, f, tx, seeded, from, &result)
})
if tr == chatstate.TransitionReconcileInvalidState && from != chatstate.StateN {
// ReconcileInvalidState does not use requireFromAllowed.
// It hits loadState successfully, sees the state is not
// Invalid, and returns a TransitionError directly.
require.Error(t, err)
var te *chatstate.TransitionError
require.ErrorAs(t, err, &te,
"reconcile from non-invalid state must return TransitionError")
require.Equal(t, chatstate.TransitionReconcileInvalidState, te.Transition)
require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed)
assertNoMutationOrPublish(ctx, t, f, seeded.chatID, base)
return
}
expectErr := expectedErrorForDisallowed(tr, from)
require.Error(t, err)
require.ErrorIs(t, err, expectErr)
assertNoMutationOrPublish(ctx, t, f, seeded.chatID, base)
}
// TestTransitionMatrix_AllCombinations is the single entry point for
// the case-level transition matrix coverage. Each positive case in
// matrixCases() is one (transition, from, want) triple with a focused
// effect assertion. Disallowed combinations are enumerated from
// transition.go to confirm every non-CreateChat (transition, from)
// pair outside the allowed set surfaces the right sentinel error.
//
// After all parallel subtests complete the test verifies that the
// positive coverage matches AllowedExecutionTransitionOutputs (no
// missing key, no unexpected key) and that every disallowed
// (transition, from) pair was exercised exactly once.
//
// Input-specific rejection tests live in TestTransitionInputValidation
// and are intentionally not part of this matrix entry point so the
// matrix focus stays on positive cases and generated disallowed cases.
func TestTransitionMatrix_AllCombinations(t *testing.T) {
t.Parallel()
cases := matrixCases()
// Detect duplicate full keys and duplicate subtest names. The
// coverage key intentionally ignores scenario, so two cases may
// share the same (transition, from, want) only when their
// scenarios differ.
seenFullKeys := make(map[fullCaseKey]string, len(cases))
seenNames := make(map[string]struct{}, len(cases))
for _, tc := range cases {
full := tc.fullKey()
name := tc.subtestName()
if prev, ok := seenFullKeys[full]; ok {
t.Fatalf("duplicate matrix case %+v: previous %s, new %s", full, prev, name)
}
seenFullKeys[full] = name
if _, ok := seenNames[name]; ok {
t.Fatalf("duplicate matrix subtest name %s", name)
}
seenNames[name] = struct{}{}
}
// Build the expected positive set from the matrix in
// transition.go. CreateChat is intentionally excluded because
// it is not exercised via ChatMachine.Update.
expectedPositive := make(map[caseKey]struct{})
for _, from := range chatstate.AllExecutionStates {
for _, tr := range chatstate.AllowedExecutionTransitionsFrom(from) {
if tr == chatstate.TransitionCreateChat {
continue
}
for _, to := range chatstate.AllowedExecutionTransitionOutputs(from, tr) {
expectedPositive[caseKey{transition: tr, from: from, want: to}] = struct{}{}
}
}
}
// Build the expected disallowed set: for each non-CreateChat
// transition, every state where the transition is not allowed.
expectedDisallowed := make(map[disallowedCaseKey]struct{})
for _, tr := range chatstate.AllExecutionTransitions {
if tr == chatstate.TransitionCreateChat {
continue
}
for _, from := range chatstate.AllExecutionStates {
if transitionAllowed(tr, from) {
continue
}
expectedDisallowed[disallowedCaseKey{transition: tr, from: from}] = struct{}{}
}
}
// Validate that every case in matrixCases describes a
// (transition, from, want) combination that the matrix actually
// admits. This guards against typos in matrixCases wiring up
// nonsense cases that happen to compile.
for _, tc := range cases {
if tc.assertFailure != nil {
continue
}
key := tc.key()
_, ok := expectedPositive[key]
require.True(t, ok,
"case %s is not in the allowed (transition, from, want) set", tc.subtestName())
}
// actualPositive and actualDisallowed are mutated under mu from
// parallel subtests. The final comparison runs in t.Cleanup,
// which fires only after every parallel child finishes.
var mu sync.Mutex
actualPositive := make(map[caseKey]struct{}, len(expectedPositive))
actualDisallowed := make(map[disallowedCaseKey]struct{}, len(expectedDisallowed))
t.Cleanup(func() {
mu.Lock()
defer mu.Unlock()
for k := range expectedPositive {
if _, ok := actualPositive[k]; !ok {
t.Errorf("matrix coverage: missing positive case %+v", k)
}
}
for k := range actualPositive {
if _, ok := expectedPositive[k]; !ok {
t.Errorf("matrix coverage: unexpected positive case %+v", k)
}
}
for k := range expectedDisallowed {
if _, ok := actualDisallowed[k]; !ok {
t.Errorf("matrix coverage: missing disallowed case %+v", k)
}
}
for k := range actualDisallowed {
if _, ok := expectedDisallowed[k]; !ok {
t.Errorf("matrix coverage: unexpected disallowed case %+v", k)
}
}
})
// Positive cases: one parallel subtest per case.
t.Run("positive", func(t *testing.T) {
t.Parallel()
for _, tc := range cases {
tc := tc
t.Run(tc.subtestName(), func(t *testing.T) {
t.Parallel()
if tc.assertFailure == nil {
mu.Lock()
actualPositive[tc.key()] = struct{}{}
mu.Unlock()
}
runPositiveCase(t, tc)
})
}
})
// Negative cases: one parallel subtest per (transition, from)
// pair where the transition is not allowed. Iterate over
// transitions in canonical order, and within each transition
// iterate states in canonical AllExecutionStates order, so
// subtest names are stable.
t.Run("disallowed", func(t *testing.T) {
t.Parallel()
// Sort disallowed keys for deterministic subtest names.
// AllExecutionTransitions and AllExecutionStates are
// already canonical, so iterate in their order.
for _, tr := range chatstate.AllExecutionTransitions {
tr := tr
if tr == chatstate.TransitionCreateChat {
continue
}
t.Run(string(tr), func(t *testing.T) {
t.Parallel()
for _, from := range chatstate.AllExecutionStates {
from := from
if transitionAllowed(tr, from) {
continue
}
t.Run(string(from), func(t *testing.T) {
t.Parallel()
mu.Lock()
actualDisallowed[disallowedCaseKey{transition: tr, from: from}] = struct{}{}
mu.Unlock()
runDisallowedCase(t, tr, from)
})
}
})
}
})
}
// Positive case specs.
//
// Each case asserts (at minimum) the resulting classified post-state
// matches want, plus one transition-specific effect. Helpers reused
// from other tests handle the snapshot bump and the chat:update
// publish; per-case assertions focus on what the transition meant to
// change.
func matrixCases() []transitionCaseSpec {
return []transitionCaseSpec{
// SetArchived cases: each archived/unarchived pair flips the
// archived flag, preserves status, history and last_error,
// and does not insert anything new.
setArchivedCase(chatstate.StateW, chatstate.StateXW, database.ChatStatusWaiting),
setArchivedCase(chatstate.StateE0, chatstate.StateXE0, database.ChatStatusError),
setArchivedCase(chatstate.StateE1, chatstate.StateXE1, database.ChatStatusError),
setArchivedCase(chatstate.StateXW, chatstate.StateW, database.ChatStatusWaiting),
setArchivedCase(chatstate.StateXE0, chatstate.StateE0, database.ChatStatusError),
setArchivedCase(chatstate.StateXE1, chatstate.StateE1, database.ChatStatusError),
// SendMessage(queue) cases: idle states insert directly,
// busy states append to the queue tail.
sendMessageQueueCase(chatstate.StateW, chatstate.StateR0, true, 0),
sendMessageQueueCase(chatstate.StateE0, chatstate.StateR0, true, 0),
// E1 promotes the queue head and queues the new tail, so
// the net queue delta is zero.
sendMessageQueueCase(chatstate.StateE1, chatstate.StateR1, false, 0),
sendMessageQueueCase(chatstate.StateR0, chatstate.StateR1, false, +1),
sendMessageQueueCase(chatstate.StateR1, chatstate.StateR1, false, +1),
sendMessageQueueCase(chatstate.StateI0, chatstate.StateI1, false, +1),
sendMessageQueueCase(chatstate.StateI1, chatstate.StateI1, false, +1),
sendMessageQueueCase(chatstate.StateA0, chatstate.StateA1, false, +1),
sendMessageQueueCase(chatstate.StateA1, chatstate.StateA1, false, +1),
// SendMessage(interrupt) cases. The interrupt applier runs
// with body "sm-interrupt" so the assertion can prove the
// interrupt input path was taken. From W/E0/E1/I0/I1 the
// resulting (transition, from, want) coverage key is
// identical to the queue case, but we still exercise the
// interrupt entry point to guard against a future bug where
// it stops routing through the correct direct-insert /
// queue-tail / promotion paths. From the busy R0/R1/A0/A1
// states the interrupt destination differs from the queue
// destination so the scenario label is the only case for that key.
sendMessageInterruptCase(chatstate.StateW, chatstate.StateR0),
sendMessageInterruptCase(chatstate.StateE0, chatstate.StateR0),
sendMessageInterruptCase(chatstate.StateE1, chatstate.StateR1),
sendMessageInterruptCase(chatstate.StateR0, chatstate.StateI1),
sendMessageInterruptCase(chatstate.StateR1, chatstate.StateI1),
sendMessageInterruptCase(chatstate.StateI0, chatstate.StateI1),
sendMessageInterruptCase(chatstate.StateI1, chatstate.StateI1),
sendMessageInterruptCase(chatstate.StateA0, chatstate.StateR1),
sendMessageInterruptCase(chatstate.StateA1, chatstate.StateR1),
// EditMessage cases: every allowed source state lands in R0
// with the queue cleared, last_error reset, and a
// replacement user message in active history.
editMessageCase(chatstate.StateW),
editMessageCase(chatstate.StateE0),
editMessageCase(chatstate.StateE1),
editMessageCase(chatstate.StateR0),
editMessageCase(chatstate.StateR1),
editMessageCase(chatstate.StateI0),
editMessageCase(chatstate.StateI1),
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.
deleteQueuedCase(chatstate.StateE1, chatstate.StateE0, queueShapeDefault),
deleteQueuedCase(chatstate.StateE1, chatstate.StateE1, queueShapeMulti),
deleteQueuedCase(chatstate.StateR1, chatstate.StateR0, queueShapeDefault),
deleteQueuedCase(chatstate.StateR1, chatstate.StateR1, queueShapeMulti),
deleteQueuedCase(chatstate.StateI1, chatstate.StateI0, queueShapeDefault),
deleteQueuedCase(chatstate.StateI1, chatstate.StateI1, queueShapeMulti),
deleteQueuedCase(chatstate.StateA1, chatstate.StateA0, queueShapeDefault),
deleteQueuedCase(chatstate.StateA1, chatstate.StateA1, queueShapeMulti),
// PromoteQueuedMessage cases. E1/A1 pop the head into
// history; R1/I1 only reorder the queue without
// inserting history. R1/I1 has both a head-target
// scenario (zero rows updated, queue_version unchanged)
// and a non-head scenario (target moves to head,
// queue_version advances).
promoteQueuedCase(chatstate.StateE1, chatstate.StateR0, queueShapeDefault, 0),
promoteQueuedCase(chatstate.StateE1, chatstate.StateR1, queueShapeMulti, 0),
promoteQueuedCase(chatstate.StateR1, chatstate.StateI1, queueShapeMulti, 0),
promoteQueuedCase(chatstate.StateR1, chatstate.StateI1, queueShapeMulti, 1),
promoteQueuedCase(chatstate.StateI1, chatstate.StateI1, queueShapeMulti, 1),
promoteQueuedCase(chatstate.StateA1, chatstate.StateR0, queueShapeDefault, 0),
promoteQueuedCase(chatstate.StateA1, chatstate.StateR1, queueShapeMulti, 0),
// Interrupt cases.
interruptCase(chatstate.StateR0, chatstate.StateI0),
interruptCase(chatstate.StateR1, chatstate.StateI1),
interruptCase(chatstate.StateA0, chatstate.StateR0),
interruptCase(chatstate.StateA1, chatstate.StateR1),
// CompleteRequiresAction cases: A0->R0, A1->R1.
completeRequiresActionCase(chatstate.StateA0, chatstate.StateR0),
completeRequiresActionCase(chatstate.StateA1, chatstate.StateR1),
// CancelRequiresAction cases: A0->R0, A1->R1.
cancelRequiresActionCase(chatstate.StateA0, chatstate.StateR0),
cancelRequiresActionCase(chatstate.StateA1, chatstate.StateR1),
// RecordGenerationAttempt cases: from-state preserved.
recordGenerationAttemptCase(chatstate.StateR0),
recordGenerationAttemptCase(chatstate.StateR1),
// RecordRetryState cases: from-state preserved.
recordRetryStateCase(chatstate.StateR0),
recordRetryStateCase(chatstate.StateR1),
// CommitStep cases: from-state preserved, history grows by
// one message.
commitStepCase(chatstate.StateR0),
commitStepCase(chatstate.StateR1),
// EnterRequiresAction cases. R0/R1 need a pending tool call
// seeded; use seedForEnterRequiresAction so the precondition
// is met.
enterRequiresActionCase(chatstate.StateR0, chatstate.StateA0),
enterRequiresActionCase(chatstate.StateR1, chatstate.StateA1),
// FinishInterruption cases: I0->W, I1->R0 (head promoted into
// history when only one queued), I1->R1 (with more than one
// queued, the head is promoted but the queue stays
// non-empty).
finishInterruptionCase(chatstate.StateI0, chatstate.StateW, queueShapeDefault),
finishInterruptionRejectsOutstandingToolCallCase(),
finishInterruptionCase(chatstate.StateI1, chatstate.StateR0, queueShapeDefault),
finishInterruptionCase(chatstate.StateI1, chatstate.StateR1, queueShapeMulti),
// FinishTurn cases.
finishTurnCase(chatstate.StateR0, chatstate.StateW, queueShapeDefault),
finishTurnCase(chatstate.StateR1, chatstate.StateR0, queueShapeDefault),
finishTurnCase(chatstate.StateR1, chatstate.StateR1, queueShapeMulti),
// FinishError cases.
finishErrorCase(chatstate.StateR0, chatstate.StateE0),
finishErrorCase(chatstate.StateR1, chatstate.StateE1),
// ReconcileInvalidState cases: Invalid with empty queue
// lands in E0; Invalid with non-empty queue lands in E1.
reconcileInvalidStateCase(chatstate.StateE0, queueShapeDefault),
reconcileInvalidStateCase(chatstate.StateE1, queueShapeMulti),
}
}
func setArchivedCase(from, want chatstate.ExecutionState, wantStatus database.ChatStatus) transitionCaseSpec {
wantArchived := false
switch want {
case chatstate.StateXW, chatstate.StateXE0, chatstate.StateXE1:
wantArchived = true
}
return transitionCaseSpec{
transition: chatstate.TransitionSetArchived,
from: from,
want: want,
apply: applySetArchived,
assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) {
_ = result
after, err := f.DB.GetChatByID(ctx, seeded.chatID)
require.NoError(t, err)
require.Equal(t, wantArchived, after.Archived,
"SetArchived must set archived=%v", wantArchived)
require.Equal(t, wantStatus, after.Status,
"SetArchived preserves chat status")
require.Equal(t, base.chat.LastError, after.LastError,
"SetArchived preserves last_error")
require.Equal(t, base.historyVersion, after.HistoryVersion,
"SetArchived does not insert history")
require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID),
"SetArchived leaves history messages unchanged")
require.Equal(t, base.queueVersion, after.QueueVersion,
"SetArchived does not mutate queued messages")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"SetArchived leaves queued messages unchanged")
},
}
}
func sendMessageQueueCase(from, want chatstate.ExecutionState, directInsert bool, queueDelta int64) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionSendMessage,
from: from,
want: want,
scenario: scenarioQueue,
apply: applySendMessageQueue,
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)
afterQueue, err := f.DB.CountChatQueuedMessages(ctx, seeded.chatID)
require.NoError(t, err)
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
afterQueueIDs := queuedIDsByPosition(ctx, t, f, seeded.chatID)
require.Equal(t, base.queueCount+queueDelta, afterQueue,
"SendMessage(queue): unexpected queue count delta")
switch {
case directInsert:
// W/E0: insert directly into history, no queue
// mutation. result.InsertedMessages contains exactly
// the new user message.
require.Len(t, result.sendMessage.InsertedMessages, 1,
"SendMessage(queue) into W/E0 inserts exactly one history message")
require.Nil(t, result.sendMessage.QueuedMessage,
"SendMessage(queue) into W/E0 does not queue")
inserted := assertFetchedUserMessage(ctx, t, f, result.sendMessage.InsertedMessages[0])
require.Equal(t, seeded.chatID, inserted.ChatID)
assertChatMessageText(t, inserted, "sm-queue")
require.False(t, after.LastError.Valid,
"SendMessage(queue) clears last_error when transitioning out of an error state")
require.Equal(t, database.ChatStatusRunning, after.Status,
"SendMessage(queue) into W/E0 lands in running")
require.Equal(t, base.queueIDs, afterQueueIDs,
"SendMessage(queue) into W/E0 must not touch queued messages")
require.Equal(t, base.queueVersion, after.QueueVersion,
"SendMessage(queue) into W/E0 must not bump queue_version")
require.Equal(t, append([]int64{}, base.historyIDs...), afterHistory[:len(base.historyIDs)],
"SendMessage(queue) into W/E0 leaves the existing history prefix intact")
require.Equal(t, []int64{inserted.ID}, newActiveMessageIDs(base, afterHistory),
"SendMessage(queue) into W/E0 appends exactly the new user message")
case from == chatstate.StateE1:
// E1: the previous head is promoted into history
// and replaced by the new tail. Net queue size
// unchanged.
require.NotNil(t, result.sendMessage.QueuedMessage,
"SendMessage(queue) from E1 returns the new queued tail")
require.Len(t, result.sendMessage.InsertedMessages, 1,
"SendMessage(queue) from E1 promotes the previous head into history")
promoted := assertFetchedUserMessage(ctx, t, f, result.sendMessage.InsertedMessages[0])
require.Equal(t, seeded.chatID, promoted.ChatID)
require.NotEmpty(t, seeded.queuedMessageBodies,
chatstate.StateE1.String()+" seed must record the queue head body")
assertChatMessageText(t, promoted, seeded.queuedMessageBodies[0])
newQueued := assertFetchedQueuedMessage(ctx, t, f, seeded.chatID, *result.sendMessage.QueuedMessage)
assertQueuedMessageText(t, newQueued, "sm-queue")
// Previous head queued message is gone from the
// queue and now lives in history.
require.NotEmpty(t, base.queueIDs,
chatstate.StateE1.String()+" seed must have a queue head")
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, base.queueIDs[0])
require.Equal(t, []int64{newQueued.ID}, afterQueueIDs,
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
": queue must end with only the new tail")
require.False(t, after.LastError.Valid,
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
" clears last_error")
require.Equal(t, database.ChatStatusRunning, after.Status)
require.Equal(t, []int64{promoted.ID}, newActiveMessageIDs(base, afterHistory),
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
" inserts only the promoted user message")
require.Greater(t, after.QueueVersion, base.queueVersion,
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
" advances queue_version")
default:
// Busy states: the new user message is appended at
// the queue tail; history is untouched.
require.NotNil(t, result.sendMessage.QueuedMessage,
"SendMessage(queue) from busy states returns the queued message")
require.Empty(t, result.sendMessage.InsertedMessages,
"SendMessage(queue) from busy states does not insert history")
newQueued := assertFetchedQueuedMessage(ctx, t, f, seeded.chatID, *result.sendMessage.QueuedMessage)
assertQueuedMessageText(t, newQueued, "sm-queue")
wantQueue := append(append([]int64{}, base.queueIDs...), newQueued.ID)
require.Equal(t, wantQueue, afterQueueIDs,
"SendMessage(queue) from busy states appends to the queue tail")
require.Equal(t, base.historyIDs, afterHistory,
"SendMessage(queue) from busy states does not change history")
require.Greater(t, after.QueueVersion, base.queueVersion,
"SendMessage(queue) from busy states advances queue_version")
switch from {
case chatstate.StateA0, chatstate.StateA1:
require.True(t, after.RequiresActionDeadlineAt.Valid,
"SendMessage(queue) from A* preserves requires_action_deadline_at")
require.Equal(t, base.chat.RequiresActionDeadlineAt, after.RequiresActionDeadlineAt,
"SendMessage(queue) from A* preserves the deadline value")
require.Equal(t, database.ChatStatusRequiresAction, after.Status)
case chatstate.StateI0, chatstate.StateI1:
require.Equal(t, database.ChatStatusInterrupting, after.Status)
case chatstate.StateR0, chatstate.StateR1:
require.Equal(t, database.ChatStatusRunning, after.Status)
}
}
},
}
}
func sendMessageInterruptCase(from, want chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionSendMessage,
from: from,
want: want,
scenario: scenarioInterrupt,
apply: applySendMessageInterrupt,
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)
afterQueue, err := f.DB.CountChatQueuedMessages(ctx, seeded.chatID)
require.NoError(t, err)
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
afterQueueIDs := queuedIDsByPosition(ctx, t, f, seeded.chatID)
switch from {
case chatstate.StateW, chatstate.StateE0:
// W/E0 with interrupt-mode behaves like the direct
// insert from queue-mode: the new user message lands
// directly in history, the queue is left untouched,
// last_error is cleared, and the chat lands in R0.
require.Equal(t, base.queueCount, afterQueue,
"SendMessage(interrupt) into W/E0 must not queue")
require.Nil(t, result.sendMessage.QueuedMessage,
"SendMessage(interrupt) into W/E0 does not return a queued message")
require.Len(t, result.sendMessage.InsertedMessages, 1,
"SendMessage(interrupt) into W/E0 inserts exactly one history message")
inserted := assertFetchedUserMessage(ctx, t, f, result.sendMessage.InsertedMessages[0])
require.Equal(t, seeded.chatID, inserted.ChatID)
assertChatMessageText(t, inserted, "sm-interrupt")
require.False(t, after.LastError.Valid,
"SendMessage(interrupt) into W/E0 clears last_error")
require.Equal(t, database.ChatStatusRunning, after.Status,
"SendMessage(interrupt) into W/E0 lands in running")
require.Equal(t, base.queueIDs, afterQueueIDs,
"SendMessage(interrupt) into W/E0 must not touch queued messages")
require.Equal(t, []int64{inserted.ID}, newActiveMessageIDs(base, afterHistory),
"SendMessage(interrupt) into W/E0 appends exactly the new user message")
case chatstate.StateE1:
// E1 with interrupt-mode mirrors queue-mode: the
// previous head is promoted into history and the new
// tail replaces it in the queue. Net queue size
// unchanged, last_error cleared.
require.Equal(t, base.queueCount, afterQueue,
"SendMessage(interrupt) from E1 leaves queue size unchanged")
require.NotNil(t, result.sendMessage.QueuedMessage,
"SendMessage(interrupt) from E1 returns the new queued tail")
require.Len(t, result.sendMessage.InsertedMessages, 1,
"SendMessage(interrupt) from E1 promotes the previous head into history")
promoted := assertFetchedUserMessage(ctx, t, f, result.sendMessage.InsertedMessages[0])
require.Equal(t, seeded.chatID, promoted.ChatID)
require.NotEmpty(t, seeded.queuedMessageBodies,
chatstate.StateE1.String()+" seed must record queue head body")
assertChatMessageText(t, promoted, seeded.queuedMessageBodies[0])
newQueued := assertFetchedQueuedMessage(ctx, t, f, seeded.chatID, *result.sendMessage.QueuedMessage)
assertQueuedMessageText(t, newQueued, "sm-interrupt")
require.NotEmpty(t, base.queueIDs,
chatstate.StateE1.String()+" seed must have a queue head")
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, base.queueIDs[0])
require.Equal(t, []int64{newQueued.ID}, afterQueueIDs,
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
" interrupt: queue must end with only the new tail")
require.False(t, after.LastError.Valid)
require.Equal(t, database.ChatStatusRunning, after.Status)
require.Equal(t, []int64{promoted.ID}, newActiveMessageIDs(base, afterHistory),
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
" interrupt inserts only the promoted user message")
require.Greater(t, after.QueueVersion, base.queueVersion,
chatstate.StateE1.String()+" -> "+chatstate.StateR1.String()+
" interrupt advances queue_version")
case chatstate.StateI0, chatstate.StateI1:
// I*: append to queue tail, history untouched, status
// stays interrupting.
require.Equal(t, base.queueCount+1, afterQueue,
"SendMessage(interrupt) from I* appends one queued message")
require.NotNil(t, result.sendMessage.QueuedMessage,
"SendMessage(interrupt) from I* returns the queued tail")
require.Empty(t, result.sendMessage.InsertedMessages,
"SendMessage(interrupt) from I* does not insert history")
newQueued := assertFetchedQueuedMessage(ctx, t, f, seeded.chatID, *result.sendMessage.QueuedMessage)
assertQueuedMessageText(t, newQueued, "sm-interrupt")
wantQueue := append(append([]int64{}, base.queueIDs...), newQueued.ID)
require.Equal(t, wantQueue, afterQueueIDs,
"SendMessage(interrupt) from I* appends to the queue tail")
require.Equal(t, base.historyIDs, afterHistory,
"SendMessage(interrupt) from I* must not touch history")
require.Equal(t, database.ChatStatusInterrupting, after.Status,
"SendMessage(interrupt) from I* keeps status interrupting")
require.Greater(t, after.QueueVersion, base.queueVersion,
"SendMessage(interrupt) from I* advances queue_version")
case chatstate.StateR0, chatstate.StateR1:
require.Equal(t, base.queueCount+1, afterQueue,
"SendMessage(interrupt) from R* appends one queued message")
require.NotNil(t, result.sendMessage.QueuedMessage,
"SendMessage(interrupt) from R* returns the queued tail")
newQueued := assertFetchedQueuedMessage(ctx, t, f, seeded.chatID, *result.sendMessage.QueuedMessage)
assertQueuedMessageText(t, newQueued, "sm-interrupt")
wantQueue := append(append([]int64{}, base.queueIDs...), newQueued.ID)
require.Equal(t, wantQueue, afterQueueIDs,
"SendMessage(interrupt) from R* appends to the queue tail")
require.Greater(t, after.QueueVersion, base.queueVersion,
"SendMessage(interrupt) from R* advances queue_version")
require.Equal(t, database.ChatStatusInterrupting, after.Status,
"R* -> I1 sets status interrupting")
require.Equal(t, base.historyIDs, afterHistory,
"SendMessage(interrupt) from R* must not touch history")
case chatstate.StateA0, chatstate.StateA1:
require.Equal(t, base.queueCount+1, afterQueue,
"SendMessage(interrupt) from A* appends one queued message")
require.NotNil(t, result.sendMessage.QueuedMessage,
"SendMessage(interrupt) from A* returns the queued tail")
newQueued := assertFetchedQueuedMessage(ctx, t, f, seeded.chatID, *result.sendMessage.QueuedMessage)
assertQueuedMessageText(t, newQueued, "sm-interrupt")
wantQueue := append(append([]int64{}, base.queueIDs...), newQueued.ID)
require.Equal(t, wantQueue, afterQueueIDs,
"SendMessage(interrupt) from A* appends to the queue tail")
require.Greater(t, after.QueueVersion, base.queueVersion,
"SendMessage(interrupt) from A* advances queue_version")
require.Equal(t, database.ChatStatusRunning, after.Status,
"A* -> R1 cancels pending dynamic calls and resumes running")
require.False(t, after.RequiresActionDeadlineAt.Valid,
"A* -> R1 clears requires_action_deadline_at")
// Cancellation messages for the pending dynamic
// tool call should land in active history. They are
// not returned via SendMessageResult, so we fetch
// them by diffing the active history set.
newIDs := newActiveMessageIDs(base, afterHistory)
require.Len(t, newIDs, 1,
"SendMessage(interrupt) from A* synthesizes exactly one tool-result cancellation")
cancel := requireChatMessageByID(ctx, t, f, newIDs[0])
assertToolResultForCall(t, cancel, seeded.pendingToolCallID)
}
},
}
}
func editMessageCase(from chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionEditMessage,
from: from,
want: chatstate.StateR0,
apply: applyEditMessage,
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,
"EditMessage always lands in running")
require.False(t, after.Archived, "EditMessage clears archived")
require.False(t, after.LastError.Valid,
"EditMessage clears last_error")
count, err := f.DB.CountChatQueuedMessages(ctx, seeded.chatID)
require.NoError(t, err)
require.Zero(t, count, "EditMessage clears the queue")
require.Empty(t, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"EditMessage leaves no queued messages")
// Replacement message must be a fresh user message that
// replaces the original target and lives in active history.
require.NotZero(t, result.editMessage.ReplacementMessage.ID,
"EditMessage returns the replacement message")
replacement := assertFetchedUserMessage(ctx, t, f, result.editMessage.ReplacementMessage)
require.Equal(t, seeded.chatID, replacement.ChatID)
require.NotEqual(t, seeded.initialUserMessageID, replacement.ID,
"EditMessage inserts a new replacement message")
assertChatMessageText(t, replacement, "edited")
// Every history message from the edited message onward,
// inclusive, must be soft-deleted. base.historyIDs is the
// active history in order before the transition, so the
// expected deleted suffix is everything from the target's
// position to the end of that slice. GetChatMessageByID
// filters deleted=false, so it must return an error for
// each deleted ID.
require.NotEmpty(t, result.editMessage.DeletedMessageIDs,
"EditMessage deletes at least the target user message")
targetIdx := slices.Index(base.historyIDs, seeded.initialUserMessageID)
require.GreaterOrEqual(t, targetIdx, 0,
"baseline active history must contain the edited message")
wantDeleted := append([]int64{}, base.historyIDs[targetIdx:]...)
require.Equal(t, wantDeleted, result.editMessage.DeletedMessageIDs,
"EditMessage soft-deletes the edited message and every later active history message in order")
for _, id := range result.editMessage.DeletedMessageIDs {
_, err := f.DB.GetChatMessageByID(ctx, id)
require.Error(t, err,
"EditMessage: deleted message %d must not be active", id)
}
// Every deleted queued message must be gone from the queue.
for _, id := range result.editMessage.DeletedQueuedMessageIDs {
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, id)
}
for _, id := range base.queueIDs {
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, id)
}
},
}
}
func deleteQueuedCase(from, want chatstate.ExecutionState, shape queueShape) transitionCaseSpec {
spec := transitionCaseSpec{
transition: chatstate.TransitionDeleteQueuedMessage,
from: from,
want: want,
apply: applyDeleteQueuedMessage,
assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) {
afterQueue, err := f.DB.CountChatQueuedMessages(ctx, seeded.chatID)
require.NoError(t, err)
require.Equal(t, base.queueCount-1, afterQueue,
"DeleteQueuedMessage removes exactly one queued message")
after, err := f.DB.GetChatByID(ctx, seeded.chatID)
require.NoError(t, err)
require.Greater(t, after.QueueVersion, base.queueVersion,
"DeleteQueuedMessage advances queue_version")
// The target queued message is the seeded head. It must
// be returned in DeletedQueuedMessage, and it must no
// longer be fetchable.
require.NotEmpty(t, seeded.queuedMessageIDs)
targetID := seeded.queuedMessageIDs[0]
require.Equal(t, targetID, result.deleteQueuedMessage.DeletedQueuedMessage.ID,
"DeletedQueuedMessage returns the targeted queued message")
require.Equal(t, seeded.chatID, result.deleteQueuedMessage.DeletedQueuedMessage.ChatID)
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, targetID)
// Remaining queue IDs are the baseline tail.
wantRemaining := append([]int64{}, base.queueIDs[1:]...)
require.Equal(t, wantRemaining, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"DeleteQueuedMessage preserves remaining queue order")
require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID),
"DeleteQueuedMessage does not touch history")
},
}
if shape.isMulti() {
spec.scenario = scenarioMulti
spec.seed = func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
return seedStateMultiQueued(t, f, from)
}
}
return spec
}
func promoteQueuedCase(from, want chatstate.ExecutionState, shape queueShape, targetIdx int) transitionCaseSpec {
var sc scenario
if shape.isMulti() {
switch targetIdx {
case 0:
sc = scenarioHeadTarget
default:
sc = scenarioNonHead
}
}
apply := func(t *testing.T, _ *testFixture, tx *chatstate.Tx, seeded seededChat, _ chatstate.ExecutionState, result *transitionCaseResult) error {
t.Helper()
require.Less(t, targetIdx, len(seeded.queuedMessageIDs), "promote target index out of range")
var err error
result.promoteQueuedMessage, err = tx.PromoteQueuedMessage(chatstate.PromoteQueuedMessageInput{
QueuedMessageID: seeded.queuedMessageIDs[targetIdx],
})
return err
}
spec := transitionCaseSpec{
transition: chatstate.TransitionPromoteQueuedMessage,
from: from,
want: want,
scenario: sc,
apply: apply,
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)
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
afterQueueIDs := queuedIDsByPosition(ctx, t, f, seeded.chatID)
afterQueue := int64(len(afterQueueIDs))
require.NotEmpty(t, seeded.queuedMessageIDs)
require.Less(t, targetIdx, len(seeded.queuedMessageIDs))
targetID := seeded.queuedMessageIDs[targetIdx]
require.Equal(t, targetID, result.promoteQueuedMessage.QueuedMessage.ID,
"PromoteQueuedMessage returns the targeted queued message")
switch from {
case chatstate.StateE1, chatstate.StateA1:
// Head is popped into history.
require.Equal(t, base.queueCount-1, afterQueue,
"E1/A1 promote pops the head into history")
require.Equal(t, database.ChatStatusRunning, after.Status,
"E1/A1 promote lands in running")
require.False(t, after.LastError.Valid,
"E1/A1 promote clears last_error")
require.False(t, after.RequiresActionDeadlineAt.Valid,
"E1/A1 promote clears requires_action_deadline_at")
require.NotNil(t, result.promoteQueuedMessage.InsertedMessage,
"E1/A1 promote inserts a user history message")
inserted := requireChatMessageByID(ctx, t, f, result.promoteQueuedMessage.InsertedMessage.ID)
require.Equal(t, seeded.chatID, inserted.ChatID)
require.Equal(t, database.ChatMessageRoleUser, inserted.Role)
require.True(t, inserted.ModelConfigID.Valid)
require.Equal(t, f.Model.ID, inserted.ModelConfigID.UUID)
require.Equal(t, chatprompt.CurrentContentVersion, inserted.ContentVersion)
require.True(t, inserted.CreatedBy.Valid)
require.Equal(t, result.promoteQueuedMessage.QueuedMessage.CreatedBy, inserted.CreatedBy.UUID,
"promoted history message preserves queued created_by")
if len(seeded.queuedMessageCreatedBy) > targetIdx {
require.Equal(t, seeded.queuedMessageCreatedBy[targetIdx], inserted.CreatedBy.UUID,
"promoted history message preserves non-owner queued creator")
}
require.NotEmpty(t, seeded.queuedMessageBodies,
"E1/A1 seed must record queued message bodies")
assertChatMessageText(t, inserted, seeded.queuedMessageBodies[targetIdx])
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, targetID)
wantRemaining := remainingExcluding(base.queueIDs, targetIdx)
require.Equal(t, wantRemaining, afterQueueIDs,
"E1/A1 promote leaves the remaining queue order intact")
assertQueueBodiesInOrder(ctx, t, f, seeded.chatID,
remainingBodiesExcluding(seeded.queuedMessageBodies, targetIdx))
// New active history adds exactly the inserted
// user message plus any synthetic cancellations.
newIDs := newActiveMessageIDs(base, afterHistory)
require.Contains(t, newIDs, inserted.ID,
"newly-active history contains the promoted user message")
if from == chatstate.StateA1 {
// A1: every outstanding tool call must be
// canceled before the promoted user message.
require.Len(t, result.promoteQueuedMessage.CancellationMessages, len(seeded.pendingToolCallIDs),
"A1 promote synthesizes one tool-result cancellation per outstanding call")
gotIDs := make(map[string]bool)
for _, cancelMsg := range result.promoteQueuedMessage.CancellationMessages {
cancel := requireChatMessageByID(ctx, t, f, cancelMsg.ID)
require.Less(t, cancel.ID, inserted.ID,
"A1 promote inserts cancellations before the promoted user message")
parts, err := chatprompt.ParseContent(cancel)
require.NoError(t, err)
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeToolResult {
continue
}
require.True(t, part.IsError,
"A1 promote synthetic cancellation is marked as an error")
gotIDs[part.ToolCallID] = true
}
}
for _, callID := range seeded.pendingToolCallIDs {
require.True(t, gotIDs[callID],
"A1 promote cancels outstanding tool call %s", callID)
}
} else {
require.Empty(t, result.promoteQueuedMessage.CancellationMessages,
"E1 promote has no synthetic cancellations")
}
case chatstate.StateR1, chatstate.StateI1:
// Reorder-only: status flips to interrupting, no
// history insert, queue cardinality unchanged.
require.Equal(t, base.queueCount, afterQueue,
"R1/I1 promote leaves queue cardinality unchanged")
require.Equal(t, database.ChatStatusInterrupting, after.Status,
"R1/I1 promote lands in interrupting")
require.Nil(t, result.promoteQueuedMessage.InsertedMessage,
"R1/I1 promote must not insert a history message")
require.Empty(t, result.promoteQueuedMessage.CancellationMessages,
"R1/I1 promote has no synthetic cancellations")
require.Equal(t, base.historyIDs, afterHistory,
"R1/I1 promote leaves history unchanged")
// Target must still be present and now at the head.
queued := requireQueuedMessageByID(ctx, t, f, seeded.chatID, targetID)
require.Equal(t, targetID, queued.ID)
require.NotEmpty(t, afterQueueIDs)
require.Equal(t, targetID, afterQueueIDs[0],
"R1/I1 promote brings the target to the queue head")
require.NotEmpty(t, seeded.queuedMessageBodies,
"R1/I1 seed must record queued message bodies")
if targetIdx == 0 {
// Head-target: zero rows updated, so the
// queue order is unchanged and queue_version
// stays put.
require.Equal(t, base.queueIDs, afterQueueIDs,
"head-target promote preserves queue order")
require.Equal(t, base.queueVersion, after.QueueVersion,
"head-target promote leaves queue_version unchanged")
assertQueueBodiesInOrder(ctx, t, f, seeded.chatID, seeded.queuedMessageBodies)
} else {
// Non-head: target moves to the head, the rest
// of the original order is preserved.
wantQueue := append([]int64{targetID}, remainingExcluding(base.queueIDs, targetIdx)...)
require.Equal(t, wantQueue, afterQueueIDs,
"non-head promote moves the target to the head and preserves the rest")
require.Greater(t, after.QueueVersion, base.queueVersion,
"non-head promote advances queue_version")
wantBodies := append([]string{seeded.queuedMessageBodies[targetIdx]},
remainingBodiesExcluding(seeded.queuedMessageBodies, targetIdx)...)
assertQueueBodiesInOrder(ctx, t, f, seeded.chatID, wantBodies)
}
}
},
}
if from == chatstate.StateA1 {
spec.seed = func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
queuedExtras := 1
if shape.isMulti() {
queuedExtras = 2
}
return seedA1WithMixedOutstandingToolCalls(t, f, queuedExtras, "seed_tool_a1_promote")
}
} else if shape.isMulti() {
spec.seed = func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
return seedStateMultiQueued(t, f, from)
}
}
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,
from: from,
want: want,
apply: applyInterrupt,
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)
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
afterQueueIDs := queuedIDsByPosition(ctx, t, f, seeded.chatID)
require.Equal(t, base.queueIDs, afterQueueIDs,
"Interrupt does not touch queued messages")
switch from {
case chatstate.StateR0, chatstate.StateR1:
require.Equal(t, database.ChatStatusInterrupting, after.Status,
"Interrupt from R* sets status interrupting")
require.Equal(t, base.historyIDs, afterHistory,
"Interrupt from R* leaves history unchanged")
require.Empty(t, result.interrupt.CancellationMessages,
"Interrupt from R* does not synthesize tool cancellations")
case chatstate.StateA0, chatstate.StateA1:
require.Equal(t, database.ChatStatusRunning, after.Status,
"Interrupt from A* cancels pending dynamic calls and resumes running")
require.False(t, after.RequiresActionDeadlineAt.Valid,
"Interrupt from A* clears requires_action_deadline_at")
require.Len(t, result.interrupt.CancellationMessages, 1,
"Interrupt from A* synthesizes one tool-result cancellation")
cancel := requireChatMessageByID(ctx, t, f,
result.interrupt.CancellationMessages[0].ID)
assertToolResultForCall(t, cancel, seeded.pendingToolCallID)
}
},
}
}
func completeRequiresActionCase(from, want chatstate.ExecutionState) transitionCaseSpec {
// Re-seed A0/A1 fresh per case so the pending tool call ID is
// available on the seeded chat.
return transitionCaseSpec{
transition: chatstate.TransitionCompleteRequiresAction,
from: from,
want: want,
apply: applyCompleteRequiresAction,
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,
"CompleteRequiresAction sets status running")
require.False(t, after.RequiresActionDeadlineAt.Valid,
"CompleteRequiresAction clears requires_action_deadline_at")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"CompleteRequiresAction preserves queued messages")
// The user-submitted tool result must be inserted as a
// tool-role message that references the seeded
// pendingToolCallID with is_error=false.
require.Len(t, result.completeRequiresAction.InsertedMessages, 1,
"CompleteRequiresAction inserts one tool-result message per pending call")
inserted := requireChatMessageByID(ctx, t, f,
result.completeRequiresAction.InsertedMessages[0].ID)
assertToolResultForCallNoError(t, inserted, seeded.pendingToolCallID, `{"ok":true}`)
},
}
}
func cancelRequiresActionCase(from, want chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionCancelRequiresAction,
from: from,
want: want,
apply: applyCancelRequiresAction,
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,
"CancelRequiresAction sets status running")
require.False(t, after.RequiresActionDeadlineAt.Valid,
"CancelRequiresAction clears requires_action_deadline_at")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"CancelRequiresAction preserves queued messages")
// One synthetic tool-result cancellation per pending call.
require.Len(t, result.cancelRequiresAction.CancellationMessages, 1,
"CancelRequiresAction synthesizes one tool-result per pending call")
cancel := requireChatMessageByID(ctx, t, f,
result.cancelRequiresAction.CancellationMessages[0].ID)
assertToolResultForCall(t, cancel, seeded.pendingToolCallID)
},
}
}
func recordGenerationAttemptCase(from chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionRecordGenerationAttempt,
from: from,
want: from, // state preserved
apply: applyRecordGenerationAttempt,
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, int64(1), after.GenerationAttempt,
"RecordGenerationAttempt increments generation_attempt by one")
require.Equal(t, result.recordGenerationAttempt.GenerationAttempt, after.GenerationAttempt,
"RecordGenerationAttempt result mirrors the persisted value")
require.Equal(t, base.historyVersion, after.HistoryVersion,
"RecordGenerationAttempt does not change history_version")
require.Equal(t, base.queueVersion, after.QueueVersion,
"RecordGenerationAttempt does not change queue_version")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"RecordGenerationAttempt does not change queue order")
require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID),
"RecordGenerationAttempt does not change history messages")
},
}
}
func recordRetryStateCase(from chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionRecordRetryState,
from: from,
want: from, // state preserved
apply: applyRecordRetryState,
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.True(t, after.RetryState.Valid,
"RecordRetryState stores retry_state")
require.JSONEq(t,
string(result.recordRetryState.Chat.RetryState.RawMessage),
string(after.RetryState.RawMessage),
"RecordRetryState result mirrors persisted retry_state")
require.JSONEq(t,
`{"attempt":1,"delay_ms":250,"error":"retry","retrying_at":"2026-05-29T00:00:00Z"}`,
string(after.RetryState.RawMessage),
"RecordRetryState stores the expected payload")
require.Equal(t, after.SnapshotVersion, after.RetryStateVersion,
"RecordRetryState sets retry_state_version to snapshot_version")
require.Greater(t, after.RetryStateVersion, base.retryStateVersion,
"RecordRetryState advances retry_state_version")
require.Equal(t, base.historyVersion, after.HistoryVersion,
"RecordRetryState does not change history_version")
require.Equal(t, base.queueVersion, after.QueueVersion,
"RecordRetryState does not change queue_version")
require.Equal(t, base.generationAttempt, after.GenerationAttempt,
"RecordRetryState does not change generation_attempt")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"RecordRetryState does not change queue order")
require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID),
"RecordRetryState does not change history messages")
},
}
}
func commitStepCase(from chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionCommitStep,
from: from,
want: from, // state preserved
apply: applyCommitStep,
assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) {
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
require.Equal(t, len(base.historyIDs)+1, len(afterHistory),
"CommitStep appends exactly one history message")
after, err := f.DB.GetChatByID(ctx, seeded.chatID)
require.NoError(t, err)
require.Greater(t, after.HistoryVersion, base.historyVersion,
"CommitStep advances history_version")
require.Len(t, result.commitStep.InsertedMessages, 1,
"CommitStep returns the inserted assistant message")
inserted := requireChatMessageByID(ctx, t, f,
result.commitStep.InsertedMessages[0].ID)
require.Equal(t, seeded.chatID, inserted.ChatID)
require.Equal(t, database.ChatMessageRoleAssistant, inserted.Role,
"CommitStep inserts an assistant-role message")
assertChatMessageText(t, inserted, "assistant")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"CommitStep does not change queue order")
require.Equal(t, base.queueVersion, after.QueueVersion,
"CommitStep does not change queue_version")
},
}
}
func enterRequiresActionCase(from, want chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionEnterRequiresAction,
from: from,
want: want,
seed: seedForEnterRequiresAction,
apply: applyEnterRequiresAction,
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.ChatStatusRequiresAction, after.Status,
"EnterRequiresAction sets status requires_action")
require.True(t, after.RequiresActionDeadlineAt.Valid,
"EnterRequiresAction populates requires_action_deadline_at")
require.True(t, result.enterRequiresAction.RequiresActionDeadlineAt.Valid,
"EnterRequiresAction returns the deadline")
require.Equal(t, result.enterRequiresAction.RequiresActionDeadlineAt, after.RequiresActionDeadlineAt,
"EnterRequiresAction returned deadline matches the persisted value")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"EnterRequiresAction preserves queued messages")
require.Equal(t, base.queueVersion, after.QueueVersion,
"EnterRequiresAction does not bump queue_version")
require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID),
"EnterRequiresAction does not insert history")
},
}
}
func finishInterruptionRejectsOutstandingToolCallCase() transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionFinishInterruption,
from: chatstate.StateI0,
want: chatstate.StateI0,
scenario: scenarioRejectNonDynamicOutstandingToolCall,
seed: func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
ctx := testutil.Context(t, testutil.WaitShort)
created := createTestChat(t, f)
m := chatstate.NewChatMachine(f.DB, f.Pub, created.Chat.ID)
nonDynCallID := "call_" + uuid.NewString()
commitAssistantToolCall(t, f, m,
nonDynamicAssistantToolCallMessage(t, f.Model.ID, nonDynCallID))
require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
_, err := tx.Interrupt(chatstate.InterruptInput{Reason: "test"})
return err
}))
return seededChat{
chatID: created.Chat.ID,
exists: true,
initialUserMessageID: firstUserMessageID(ctx, t, f, created.Chat.ID),
assistantToolCallMsgID: firstAssistantMessageID(ctx, t, f, created.Chat.ID),
pendingToolCallIDs: []string{nonDynCallID},
}
},
apply: applyFinishInterruption,
assertFailure: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, err error) {
require.Error(t, err, "FinishInterruption must reject an outstanding tool call")
require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed,
"rejection must wrap ErrTransitionNotAllowed")
var te *chatstate.TransitionError
require.ErrorAs(t, err, &te,
"FinishInterruption must return a typed TransitionError")
require.Equal(t, chatstate.TransitionFinishInterruption, te.Transition)
require.Equal(t, chatstate.StateI0, te.From)
assertNoMutationOrPublish(ctx, t, f, seeded.chatID, base)
},
}
}
func finishInterruptionCase(from, want chatstate.ExecutionState, shape queueShape) transitionCaseSpec {
spec := transitionCaseSpec{
transition: chatstate.TransitionFinishInterruption,
from: from,
want: want,
apply: applyFinishInterruption,
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)
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
afterQueueIDs := queuedIDsByPosition(ctx, t, f, seeded.chatID)
switch from {
case chatstate.StateI0:
require.Equal(t, database.ChatStatusWaiting, after.Status,
"FinishInterruption from I0 lands in waiting")
require.Nil(t, result.finishInterruption.PromotedMessage,
"FinishInterruption from I0 promotes nothing")
require.Equal(t, base.queueIDs, afterQueueIDs,
"FinishInterruption from I0 leaves queued messages unchanged")
require.Equal(t, base.historyIDs, afterHistory,
"FinishInterruption from I0 with no partial messages leaves history unchanged")
case chatstate.StateI1:
require.Equal(t, database.ChatStatusRunning, after.Status,
"FinishInterruption from I1 lands in running")
require.NotNil(t, result.finishInterruption.PromotedMessage,
"FinishInterruption from I1 promotes the head into history")
promoted := assertFetchedUserMessage(ctx, t, f,
*result.finishInterruption.PromotedMessage)
require.Equal(t, seeded.chatID, promoted.ChatID)
require.Contains(t, newActiveMessageIDs(base, afterHistory), promoted.ID,
"FinishInterruption from I1 inserts the promoted user message")
require.NotEmpty(t, seeded.queuedMessageBodies,
"I1 seed must record queued message bodies")
assertChatMessageText(t, promoted, seeded.queuedMessageBodies[0])
require.NotEmpty(t, base.queueIDs)
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, base.queueIDs[0])
wantRemaining := append([]int64{}, base.queueIDs[1:]...)
require.Equal(t, wantRemaining, afterQueueIDs,
"FinishInterruption from I1 preserves the queue tail order")
assertQueueBodiesInOrder(ctx, t, f, seeded.chatID,
seeded.queuedMessageBodies[1:])
}
},
}
if shape.isMulti() {
spec.scenario = scenarioMulti
spec.seed = func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
return seedStateMultiQueued(t, f, from)
}
}
return spec
}
func finishTurnCase(from, want chatstate.ExecutionState, shape queueShape) transitionCaseSpec {
spec := transitionCaseSpec{
transition: chatstate.TransitionFinishTurn,
from: from,
want: want,
apply: applyFinishTurn,
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)
afterHistory := activeHistoryIDs(ctx, t, f, seeded.chatID)
afterQueueIDs := queuedIDsByPosition(ctx, t, f, seeded.chatID)
switch from {
case chatstate.StateR0:
require.Equal(t, database.ChatStatusWaiting, after.Status,
"FinishTurn from R0 lands in waiting")
require.Nil(t, result.finishTurn.PromotedMessage,
"FinishTurn from R0 promotes nothing")
require.Equal(t, base.queueIDs, afterQueueIDs,
"FinishTurn from R0 leaves queued messages unchanged")
require.Equal(t, base.historyIDs, afterHistory,
"FinishTurn from R0 leaves history unchanged")
case chatstate.StateR1:
require.Equal(t, database.ChatStatusRunning, after.Status,
"FinishTurn from R1 lands in running")
require.NotNil(t, result.finishTurn.PromotedMessage,
"FinishTurn from R1 promotes the head into history")
promoted := assertFetchedUserMessage(ctx, t, f,
*result.finishTurn.PromotedMessage)
require.Equal(t, seeded.chatID, promoted.ChatID)
require.Contains(t, newActiveMessageIDs(base, afterHistory), promoted.ID,
"FinishTurn from R1 inserts the promoted user message")
require.NotEmpty(t, seeded.queuedMessageBodies,
"R1 seed must record queued message bodies")
assertChatMessageText(t, promoted, seeded.queuedMessageBodies[0])
require.NotEmpty(t, base.queueIDs)
requireQueuedMessageDeleted(ctx, t, f, seeded.chatID, base.queueIDs[0])
wantRemaining := append([]int64{}, base.queueIDs[1:]...)
require.Equal(t, wantRemaining, afterQueueIDs,
"FinishTurn from R1 preserves the queue tail order")
assertQueueBodiesInOrder(ctx, t, f, seeded.chatID,
seeded.queuedMessageBodies[1:])
}
},
}
if shape.isMulti() {
spec.scenario = scenarioMulti
spec.seed = func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
return seedStateMultiQueued(t, f, from)
}
}
return spec
}
func finishErrorCase(from, want chatstate.ExecutionState) transitionCaseSpec {
return transitionCaseSpec{
transition: chatstate.TransitionFinishError,
from: from,
want: want,
apply: applyFinishError,
assert: func(ctx context.Context, t *testing.T, f *testFixture, seeded seededChat, base snapshotBaseline, result transitionCaseResult) {
_ = result
after, err := f.DB.GetChatByID(ctx, seeded.chatID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusError, after.Status,
"FinishError sets status error")
require.True(t, after.LastError.Valid,
"FinishError stores last_error")
require.JSONEq(t, `{"message":"finish-error"}`, string(after.LastError.RawMessage),
"FinishError persists the input last_error JSON")
require.Equal(t, base.historyVersion, after.HistoryVersion,
"FinishError does not change history_version")
require.Equal(t, base.queueVersion, after.QueueVersion,
"FinishError does not change queue_version")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"FinishError preserves queued messages")
require.Equal(t, base.historyIDs, activeHistoryIDs(ctx, t, f, seeded.chatID),
"FinishError preserves history messages")
},
}
}
func reconcileInvalidStateCase(want chatstate.ExecutionState, shape queueShape) transitionCaseSpec {
spec := transitionCaseSpec{
transition: chatstate.TransitionReconcileInvalidState,
from: chatstate.StateInvalid,
want: want,
apply: applyReconcileInvalidState,
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.ChatStatusError, after.Status,
"ReconcileInvalidState lands in error")
require.False(t, after.Archived,
"ReconcileInvalidState clears archived")
require.True(t, after.LastError.Valid,
"ReconcileInvalidState sets a default last_error")
require.Equal(t, base.queueIDs, queuedIDsByPosition(ctx, t, f, seeded.chatID),
"ReconcileInvalidState preserves queued messages")
// For the current invalid seeds there are no pending
// dynamic tool calls, so no cancellation messages are
// expected. Still, if any are returned we fetch them
// to verify they were persisted as tool-role messages.
for _, c := range result.reconcileInvalidState.CancellationMessages {
msg := requireChatMessageByID(ctx, t, f, c.ID)
require.Equal(t, database.ChatMessageRoleTool, msg.Role)
}
},
}
if shape.isMulti() {
spec.scenario = scenarioWithQueue
spec.seed = func(t *testing.T, f *testFixture, _ chatstate.ExecutionState) seededChat {
return seedInvalidWithQueue(t, f)
}
}
return spec
}