mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
A chat that fails generation with a context overflow (for example `Input
length 262625 exceeds the maximum allowed input length of 262112
tokens`) is stuck in a catch-22: `POST /chats/{id}/compact` returns 409
because the `RequestCompaction` transition is only allowed from the
waiting state, and the only other way out of the error state is sending
or editing a message, which re-runs generation with the same oversized
prompt and fails again. Compaction is exactly the recovery a
context-overflowed chat needs, and it is unreachable exactly when it is
needed.
Three semantic changes:
- Allow `RequestCompaction` from the error states: `E0 -> R0` and `E1 ->
R1` (queued messages are preserved and processed after the compaction
turn).
- Clear `last_error` in `Tx.RequestCompaction`, matching the
architecture rule that transitions leaving `E0`/`E1` clear the stored
error. Without this a successful compaction would land in waiting with a
stale persisted error.
- Grant the compaction turn a fresh history epoch: a
`grant_history_epoch` flag on `UpdateChatExecutionState` sets
`history_version = snapshot_version`, resets `generation_attempt`, and
clears `retry_state` in the same atomic update that clears `last_error`
(mirroring the `chat_messages` trigger postcondition). The transition
inserts no history, so without this the turn inherits the failed turn's
spent retry budget, and resetting the counter alone could collide with
message part episode keys still retained on the erroring replica.
No frontend change is required: the chat input is already enabled in the
error state and `/compact` submission already handles both the success
and 409 paths. Also updates ARCHITECTURE.md (transition matrix,
endpoint, and manual compaction sections), the endpoint's swagger
description, and SDK comments.
> Mux created this PR on Mike's behalf.
<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
287 lines
9.9 KiB
Go
287 lines
9.9 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/chatretry"
|
|
"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")
|
|
}
|
|
|
|
func TestRequestCompaction_FreshHistoryEpoch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
attempts int
|
|
}{
|
|
{name: "unspent budget", attempts: 0},
|
|
{name: "one below the cap", attempts: chatretry.MaxAttempts - 1},
|
|
{name: "exhausted budget", attempts: chatretry.MaxAttempts},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
f := newTestFixture(t)
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
seeded := seedState(t, f, chatstate.StateE0)
|
|
for range tc.attempts {
|
|
_, err := f.DB.IncrementChatGenerationAttempt(ctx, seeded.chatID)
|
|
require.NoError(t, err)
|
|
}
|
|
_, err := f.DB.UpdateChatRetryState(ctx, database.UpdateChatRetryStateParams{
|
|
ID: seeded.chatID,
|
|
RetryState: []byte(`{"attempt":1}`),
|
|
})
|
|
require.NoError(t, err)
|
|
before := f.readChat(ctx, t, seeded.chatID)
|
|
|
|
m := chatstate.NewChatMachine(f.DB, f.Pub, seeded.chatID)
|
|
require.NoError(t, m.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
_, err := tx.RequestCompaction(chatstate.RequestCompactionInput{})
|
|
return err
|
|
}))
|
|
|
|
chat := f.readChat(ctx, t, seeded.chatID)
|
|
require.Zero(t, chat.GenerationAttempt)
|
|
require.Greater(t, chat.HistoryVersion, before.HistoryVersion,
|
|
"epoch must advance past every version the previous turn's episode keys used")
|
|
require.Equal(t, chat.SnapshotVersion, chat.HistoryVersion)
|
|
require.False(t, chat.RetryState.Valid,
|
|
"a stale retry payload must not survive into the fresh epoch, even at attempt 0 where the generation_attempt trigger cannot clear it")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequestCompaction_RejectedWhenBusyOrArchived pins the matrix
|
|
// boundaries callers rely on for 409 mapping: only W, E0, and E1
|
|
// admit the transition.
|
|
func TestRequestCompaction_RejectedWhenBusyOrArchived(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, from := range []chatstate.ExecutionState{
|
|
chatstate.StateR0, 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)
|
|
})
|
|
}
|
|
}
|