mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user