From e9951b07d447cd72449d0a34c6e10cf6b4683756 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:40:53 +0200 Subject: [PATCH] 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. --- coderd/x/chatd/chatloop/chatloop.go | 24 ++++++++++-- .../chatloop/contentfilter_internal_test.go | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index ff659f1021..bb46e3f276 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -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, diff --git a/coderd/x/chatd/chatloop/contentfilter_internal_test.go b/coderd/x/chatd/chatloop/contentfilter_internal_test.go index db7b571add..a57aca48b1 100644 --- a/coderd/x/chatd/chatloop/contentfilter_internal_test.go +++ b/coderd/x/chatd/chatloop/contentfilter_internal_test.go @@ -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()