From bba267645d2d7095fcebe84e3021ebca9b2e98ce Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:10:11 +0000 Subject: [PATCH] fix(coderd/x/chatd/chatloop): surface reasoning-only content-filter refusals as terminal errors --- 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 ff659f1021a68..bb46e3f27686b 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 db7b571addc61..a57aca48b1cd4 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()