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,