fix(coderd/x/chatd/chatloop): surface reasoning-only content-filter refusals as terminal errors (#27476)

## Problem

Anthropic can end a stream with `stop_reason: "refusal"` after reasoning
content has already streamed. The content-filter guard in
`chatloop.GenerateAssistant` only fired when the step content was
completely empty, so a reasoning-only refusal bypassed it: the turn
finished as `status=waiting` with `last_error=null`, and the user saw
the chat silently stop mid-turn with no explanation. This looked like a
Coder fault when the provider had rejected the response. Observed twice
in dogfood on 2026-07-23 (chat `c72f99fc`, debug steps show
`finish_reason=content-filter` with reasoning-only content).

## Change

Treat a content-filter finish as terminal whenever the step produced no
user-visible output. A new `hasUserVisibleContent` helper counts any
non-reasoning part (text, tool call, tool result) as user-visible;
reasoning-only or empty steps now return the existing
`contentFilterError`, which flows through the established pipeline:
classified `ChatErrorKindContentFilter` (non-retryable, refusal
category/detail when provided), persisted `chats.last_error`, streamed
error event, and the "Response blocked" callout in the chat UI.

Behavior for steps with visible text or tool calls is unchanged, and the
frontend needs no changes.

## Testing

- New regression subtest `ReasoningOnlyContentSurfacesTerminalError`
(reasoning stream then content-filter finish) beside the existing
empty-content and partial-content subtests, which are unchanged.
- `go test ./coderd/x/chatd/...` and lint pass.
- Dogfood UAT against a local dev instance with a mock Anthropic
upstream passed all three scenarios: reasoning-only refusal shows the
"Response blocked" callout with `last_error.kind=content_filter` and no
retry affordance; text-then-refusal still completes normally; empty
refusal still errors.

> This PR was created by Mux acting on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-07-24 19:40:53 +02:00
committed by GitHub
parent 591f357574
commit e9951b07d4
2 changed files with 59 additions and 4 deletions
+20 -4
View File
@@ -439,10 +439,12 @@ func GenerateAssistant(ctx context.Context, opts GenerateAssistantOptions) (Assi
ctx, opts.Logger, provider, modelName,
"assistant_helper", 0, result.finishReason, result.content,
)
// A content-filter finish with no content means the provider's
// safety classifiers blocked the whole response (e.g. Anthropic
// stop_reason "refusal").
if len(result.content) == 0 && result.finishReason == fantasy.FinishReasonContentFilter {
// A content-filter finish without user-visible output means the
// provider's safety classifiers blocked the whole response (e.g.
// Anthropic stop_reason "refusal"). The refusal can arrive after
// reasoning has already streamed, so reasoning alone must not
// count as output.
if result.finishReason == fantasy.FinishReasonContentFilter && !hasUserVisibleContent(result.content) {
return AssistantOutcome{}, contentFilterError(errorProvider, result.providerMetadata)
}
step := PersistedStep{
@@ -479,6 +481,20 @@ func wrapProviderStreamError(provider string, err error) error {
return xerrors.Errorf("stream response: %w", chaterror.WithClassification(err, classified))
}
// hasUserVisibleContent reports whether any content part carries output the
// user can see. Reasoning parts do not count: they stream transiently and are
// not a substitute for a response.
func hasUserVisibleContent(content []fantasy.Content) bool {
for _, part := range content {
switch part.(type) {
case fantasy.ReasoningContent, *fantasy.ReasoningContent:
default:
return true
}
}
return false
}
func contentFilterError(provider string, metadata fantasy.ProviderMetadata) error {
classified := chaterror.ClassifiedError{
Kind: codersdk.ChatErrorKindContentFilter,
@@ -120,6 +120,45 @@ func TestGenerateAssistant_ContentFilterRefusal(t *testing.T) {
require.Equal(t, "The response was blocked.", classified.Detail)
})
t.Run("ReasoningOnlyContentSurfacesTerminalError", func(t *testing.T) {
t.Parallel()
model := &chattest.FakeModel{
ProviderName: "anthropic",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeReasoningStart, ID: "reasoning-1"},
{Type: fantasy.StreamPartTypeReasoningDelta, ID: "reasoning-1", Delta: "planning next steps"},
{Type: fantasy.StreamPartTypeReasoningEnd, ID: "reasoning-1"},
{
Type: fantasy.StreamPartTypeFinish,
FinishReason: fantasy.FinishReasonContentFilter,
ProviderMetadata: refusalProviderMetadataForTest(
"harmful_content", "The response was blocked.",
),
},
}), nil
},
}
outcome, err := GenerateAssistant(context.Background(), GenerateAssistantOptions{
Model: model,
Messages: []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "hello"),
},
})
require.ErrorIs(t, err, ErrContentFiltered)
require.Empty(t, outcome.Step.Content)
classified := chaterror.Classify(err)
require.Equal(t, codersdk.ChatErrorKindContentFilter, classified.Kind)
require.Equal(t, "anthropic", classified.Provider)
require.False(t, classified.Retryable)
require.Equal(t, "Anthropic blocked this response under its content policy (harmful_content).", classified.Message)
require.Equal(t, "The response was blocked.", classified.Detail)
})
t.Run("PartialContentIsPersistedNotErrored", func(t *testing.T) {
t.Parallel()