fix: resolve bugs in chatd streaming system (#22720)

Split from #22693 per review feedback.

Fixes multiple bugs in coderd/chatd and sub-packages including race
conditions, transaction safety, stream buffer bounds, retry limits, and
enterprise relay improvements.

See commit message for full list.
This commit is contained in:
Kyle Carberry
2026-03-06 21:02:25 +00:00
committed by GitHub
parent 2cd871e88f
commit eecb7d0b66
7 changed files with 529 additions and 635 deletions
+16 -11
View File
@@ -73,7 +73,9 @@ type RunOptions struct {
// OnRetry is called before each retry attempt when the LLM
// stream fails with a retryable error. It provides the attempt
// number, error, and backoff delay so callers can publish status
// events to connected clients.
// events to connected clients. Callers should also clear any
// buffered stream state from the failed attempt in this callback
// to avoid sending duplicated content.
OnRetry chatretry.OnRetryFn
OnInterruptedPersistError func(error)
@@ -209,6 +211,10 @@ func Run(ctx context.Context, opts RunOptions) error {
var lastUsage fantasy.Usage
var lastProviderMetadata fantasy.ProviderMetadata
totalSteps := 0
// When totalSteps reaches MaxSteps the inner loop exits immediately
// (its condition is false), stoppedByModel stays false, and the
// post-loop guard breaks the outer compaction loop.
for compactionAttempt := 0; ; compactionAttempt++ {
alreadyCompacted := false
// stoppedByModel is true when the inner step loop
@@ -222,7 +228,8 @@ func Run(ctx context.Context, opts RunOptions) error {
// agent never had a chance to use the compacted context.
compactedOnFinalStep := false
for step := 0; step < opts.MaxSteps; step++ {
for step := 0; totalSteps < opts.MaxSteps; step++ {
totalSteps++
// Copy messages so that provider-specific caching
// mutations don't leak back to the caller's slice.
// copy copies Message structs by value, so field
@@ -321,6 +328,12 @@ func Run(ctx context.Context, opts RunOptions) error {
lastUsage = result.usage
lastProviderMetadata = result.providerMetadata
// Append the step's response messages so that both
// inline and post-loop compaction see the full
// conversation including the latest assistant reply.
stepMessages := result.toResponseMessages()
messages = append(messages, stepMessages...)
// Inline compaction.
if opts.Compaction != nil && opts.ReloadMessages != nil {
did, compactErr := tryCompact(
@@ -354,17 +367,11 @@ func Run(ctx context.Context, opts RunOptions) error {
// The agent is continuing with tool calls, so any
// prior compaction has already been consumed.
compactedOnFinalStep = false
// Build messages from the step for the next iteration.
// toResponseMessages produces assistant-role content
// (text, reasoning, tool calls) and tool-result content.
stepMessages := result.toResponseMessages()
messages = append(messages, stepMessages...)
}
// Post-run compaction safety net: if we never compacted
// during the loop, try once at the end.
if !alreadyCompacted && opts.Compaction != nil {
if !alreadyCompacted && opts.Compaction != nil && opts.ReloadMessages != nil {
did, err := tryCompact(
ctx,
opts.Model,
@@ -383,7 +390,6 @@ func Run(ctx context.Context, opts RunOptions) error {
compactedOnFinalStep = true
}
}
// Re-enter the step loop when compaction fired on the
// model's final step. This lets the agent continue
// working with fresh summarized context instead of
@@ -514,7 +520,6 @@ func processStepStream(
})
}
}
case fantasy.StreamPartTypeToolInputStart:
activeToolCalls[part.ID] = &fantasy.ToolCallContent{
ToolCallID: part.ID,
+2 -1
View File
@@ -123,7 +123,8 @@ func tryCompact(
config.SystemSummaryPrefix + "\n\n" + summary,
)
err = config.Persist(ctx, CompactionResult{
persistCtx := context.WithoutCancel(ctx)
err = config.Persist(persistCtx, CompactionResult{
SystemSummary: systemSummary,
SummaryReport: summary,
ThresholdPercent: config.ThresholdPercent,
+29 -1
View File
@@ -76,9 +76,20 @@ func TestRun_Compaction(t *testing.T) {
return nil
},
},
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
return []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "hello"),
}, nil
},
})
require.NoError(t, err)
require.Equal(t, 1, persistCompactionCalls)
// Compaction fires twice: once inline when the threshold is
// reached on step 0 (the only step, since MaxSteps=1), and
// once from the post-run safety net during the re-entry
// iteration (where totalSteps already equals MaxSteps so the
// inner loop doesn't execute, but lastUsage still exceeds
// the threshold).
require.Equal(t, 2, persistCompactionCalls)
require.Contains(t, persistedCompaction.SystemSummary, summaryText)
require.Equal(t, summaryText, persistedCompaction.SummaryReport)
require.Equal(t, int64(80), persistedCompaction.ContextTokens)
@@ -151,13 +162,25 @@ func TestRun_Compaction(t *testing.T) {
return nil
},
},
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
return []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "hello"),
}, nil
},
})
require.NoError(t, err)
// Compaction fires twice (see PersistsWhenThresholdReached
// for the full explanation). Each cycle follows the order:
// publish_tool_call → generate → persist → publish_tool_result.
require.Equal(t, []string{
"publish_tool_call",
"generate",
"persist",
"publish_tool_result",
"publish_tool_call",
"generate",
"persist",
"publish_tool_result",
}, callOrder)
})
@@ -457,6 +480,11 @@ func TestRun_Compaction(t *testing.T) {
compactionErr = err
},
},
ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) {
return []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "hello"),
}, nil
},
})
require.NoError(t, err)
require.Error(t, compactionErr)