Files
coder/coderd/x/chatd/chatloop
Michael Suchacz e9951b07d4 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.
2026-07-24 19:40:53 +02:00
..