fix(core): surface Anthropic refusal details (#16079)

* fix(core): surface Anthropic refusal details

Propagate Anthropic refusal stop_details through candidate finishMessage and the Finished event so both terminal UIs can show the provider's refusal category instead of a generic empty safety stop.

Co-authored-by: Qwen-Coder <[email protected]>

* style(core): apply Prettier to the refusal-detail diff

The incremental lint gate selected 12 changed files and Prettier rejected
four of them; this commit only re-wraps those lines, no behaviour change.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvygjkzk7s

* fix(core,cli): scope the refusal notice to safety stops

Review follow-up on #16079:

- Both renderers now prefer `finishMessage` only for `SAFETY` stops. The
  field is a provider wire field (`@google/genai` types it), so a Gemini turn
  carrying it could otherwise replace the curated notice for `MAX_TOKENS` and
  even add an info line on `STOP`.
- The refusal category lookup uses an own-property check: a provider `category`
  of `__proto__` / `constructor` now falls back to the generic notice instead
  of returning an object that renders as `[object Object]`.
- `llm-chat` deletes `finishMessage` together with the deferred
  `finishReason`, so the stream no longer yields a notice with no reason.
- Tests: category table (all mapped categories + prototype key + unknown) and
  a non-safety renderer case pinning the curated notice.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw651acyim

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-11-07 23:42:24 +08:00
1 parent 588db8b137
commit a32684604a
12 files changed
+285 -5

No files matched your search

@@ -20107,6 +20107,35 @@ describe('useLlmStream', () => {
expect(infoMessages).toHaveLength(0); expect(infoMessages).toHaveLength(0);
}); });
it('should prefer a provider refusal message', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerLlmEventType.Finished,
value: {
reason: 'SAFETY',
usageMetadata: undefined,
finishMessage:
'Response refused due to cyber-related safety policy.',
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('Test refusal');
});
const infoItems = mockAddItem.mock.calls
.map(([item]) => item as HistoryItem)
.filter((item) => item.type === 'info');
expect(infoItems).toContainEqual({
type: 'info',
text: '⚠ Response refused due to cyber-related safety policy.',
});
});
it('should not add message for FINISH_REASON_UNSPECIFIED', async () => { it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED // Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
mockSendMessageStream.mockReturnValue( mockSendMessageStream.mockReturnValue(
+7 -1
View File
@@ -3002,7 +3002,13 @@ export const useLlmStream = (
'Response stopped due to unexpected tool call.', 'Response stopped due to unexpected tool call.',
}; };
const message = finishReasonMessages[finishReason]; // `finishMessage` is provider-authored free text; only the safety path
// curates it (Anthropic refusal details), so other reasons keep their
// maintained notice even if a provider fills the field (#9005).
const message =
(finishReason === FinishReason.SAFETY
? event.value.finishMessage
: undefined) ?? finishReasonMessages[finishReason];
if (message) { if (message) {
addItem( addItem(
{ {
@@ -278,6 +278,42 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
text: '⚠ Response stopped due to image safety violations.', text: '⚠ Response stopped due to image safety violations.',
}); });
}); });
it('prefers the provider refusal message', () => {
const map = createEventMapper();
const out = map({
type: 'finished',
value: {
reason: 'SAFETY',
finishMessage: 'Response refused due to cyber-related safety policy.',
},
} as unknown as AnyEv);
expect(out).toContainEqual({
type: 'info',
text: '⚠ Response refused due to cyber-related safety policy.',
});
});
it('keeps the curated notice when a non-safety reason carries provider text', () => {
const map = createEventMapper();
const out = map({
type: 'finished',
value: {
reason: 'MAX_TOKENS',
finishMessage: 'Provider-authored stop detail.',
},
} as unknown as AnyEv);
expect(out).toContainEqual({
type: 'info',
text: '⚠ Response truncated due to token limits.',
});
expect(
out.some(
(item) =>
item.type === 'info' && item.text.includes('Provider-authored'),
),
).toBe(false);
});
}); });
describe('error events', () => { describe('error events', () => {
+10 -2
View File
@@ -953,8 +953,16 @@ export function createEventMapper(
out.push({ type: 'retry-countdown-clear' }); out.push({ type: 'retry-countdown-clear' });
// ink parity: handleFinishedEvent adds `{type: 'info'}` for // ink parity: handleFinishedEvent adds `{type: 'info'}` for
// non-STOP finish reasons. // non-STOP finish reasons.
const reason = (ev.value as { reason?: string } | undefined)?.reason; const value = ev.value as
const message = reason ? FINISH_REASON_NOTICES[reason] : undefined; | { reason?: string; finishMessage?: string }
| undefined;
const reason = value?.reason;
// `finishMessage` is provider-authored free text; only the safety path
// curates it (Anthropic refusal details), so other reasons keep their
// maintained notice even if a provider fills the field (#9005).
const message =
(reason === 'SAFETY' ? value?.finishMessage : undefined) ??
(reason ? FINISH_REASON_NOTICES[reason] : undefined);
if (message) out.push({ type: 'info', text: `⚠ ${message}` }); if (message) out.push({ type: 'info', text: `⚠ ${message}` });
// Segment marker only — the turn settles when the live generator // Segment marker only — the turn settles when the live generator
// returns (backend emits `done`), NOT here: `finished` arrives // returns (backend emits `done`), NOT here: `finished` arrives
@@ -191,9 +191,10 @@ const messageDelta = (
stop_reason: string, stop_reason: string,
output_tokens: number, output_tokens: number,
usage: object = {}, usage: object = {},
stop_details?: object,
): SseEvent => ({ ): SseEvent => ({
type: 'message_delta', type: 'message_delta',
delta: { stop_reason }, delta: { stop_reason, ...(stop_details ? { stop_details } : {}) },
usage: { output_tokens, ...usage }, usage: { output_tokens, ...usage },
}); });
// Yields `events`, then fails with `error`. // Yields `events`, then fails with `error`.
@@ -2452,6 +2453,27 @@ describe('AnthropicContentGenerator', () => {
expect(lastFinish(chunks)).toBe(FinishReason.STOP); expect(lastFinish(chunks)).toBe(FinishReason.STOP);
}); });
it('surfaces a refusal stop-details category', async () => {
const { chunks, error } = await collectEvents(
messageDelta(
'refusal',
1,
{},
{
type: 'refusal',
category: 'cyber',
explanation: 'Provider-authored explanation',
},
),
);
expect(error).toBeUndefined();
expect(lastFinish(chunks)).toBe(FinishReason.SAFETY);
expect(
chunks.flatMap((chunk) => chunk.candidates ?? []).at(-1)?.finishMessage,
).toBe('Response refused due to cyber-related safety policy.');
});
it.each([ it.each([
[ [
'rejects an open tool-use block after assistant payload at end of stream', 'rejects an open tool-use block after assistant payload at end of stream',
@@ -1414,6 +1414,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
let completionTokens = 0; let completionTokens = 0;
let completionTokensReported = false; let completionTokensReported = false;
let finishReason: string | undefined; let finishReason: string | undefined;
let finishMessage: string | undefined;
const blocks = new Map<number, StreamingBlockState>(); const blocks = new Map<number, StreamingBlockState>();
const deferredToolCalls: GenerateContentResponse[] = []; const deferredToolCalls: GenerateContentResponse[] = [];
@@ -1620,6 +1621,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
} }
case 'message_delta': { case 'message_delta': {
const stopReasonValue = event.delta.stop_reason; const stopReasonValue = event.delta.stop_reason;
finishMessage ??=
this.converter.mapAnthropicStopDetailsToFinishMessage(
(event.delta as { stop_details?: unknown }).stop_details,
);
if (stopReasonValue) { if (stopReasonValue) {
finishReason = stopReasonValue; finishReason = stopReasonValue;
const hasOpenToolCall = [...blocks.values()].some( const hasOpenToolCall = [...blocks.values()].some(
@@ -1719,6 +1724,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
cacheReadTokensReported: cachedTokensReported, cacheReadTokensReported: cachedTokensReported,
cacheCreationTokensReported, cacheCreationTokensReported,
}), }),
finishMessage,
); );
collectedResponses.push(chunk); collectedResponses.push(chunk);
yield chunk; yield chunk;
@@ -1748,6 +1754,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
cacheReadTokensReported: cachedTokensReported, cacheReadTokensReported: cachedTokensReported,
cacheCreationTokensReported, cacheCreationTokensReported,
}), }),
finishMessage,
); );
collectedResponses.push(chunk); collectedResponses.push(chunk);
yield chunk; yield chunk;
@@ -1895,6 +1902,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
model?: string, model?: string,
finishReason?: string, finishReason?: string,
usageMetadata?: GenerateContentResponseUsageMetadata, usageMetadata?: GenerateContentResponseUsageMetadata,
finishMessage?: string,
): GenerateContentResponse { ): GenerateContentResponse {
const response = new GenerateContentResponse(); const response = new GenerateContentResponse();
response.responseId = responseId; response.responseId = responseId;
@@ -1916,6 +1924,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
index: 0, index: 0,
safetyRatings: [], safetyRatings: [],
...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}), ...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}),
...(finishMessage ? { finishMessage } : {}),
}, },
]; ];
@@ -1740,6 +1740,58 @@ describe('AnthropicContentConverter', () => {
expect(parts).toEqual([fnCall('tool', { x: 1 }, 't1')]); expect(parts).toEqual([fnCall('tool', { x: 1 }, 't1')]);
}); });
it('surfaces a refusal stop-details category', () => {
const response = respond({
stop_reason: 'refusal',
stop_details: {
type: 'refusal',
category: 'reasoning_extraction',
explanation: 'Provider-authored explanation',
},
});
expect(response.candidates?.[0]).toEqual(
expect.objectContaining({
finishReason: FinishReason.SAFETY,
finishMessage:
'Response refused because it requested hidden reasoning.',
}),
);
});
it.each([
['cyber', 'Response refused due to cyber-related safety policy.'],
['bio', 'Response refused due to biological safety policy.'],
[
'frontier_llm',
'Response refused due to frontier model development policy.',
],
['general_harms', 'Response refused due to a general safety policy.'],
// Provider text can be any string, including prototype keys: those must
// land on the generic notice instead of leaking `[object Object]`.
['__proto__', 'Response refused due to safety reasons.'],
['unmapped_category', 'Response refused due to safety reasons.'],
])(
'maps the %s refusal category to its curated notice',
(category, expected) => {
const response = respond({
stop_reason: 'refusal',
stop_details: { type: 'refusal', category },
});
expect(response.candidates?.[0]?.finishMessage).toBe(expected);
},
);
it('ignores stop_details that is not a refusal', () => {
const response = respond({
stop_reason: 'end_turn',
stop_details: { type: 'something_else', category: 'cyber' },
});
expect(response.candidates?.[0]?.finishMessage).toBeUndefined();
});
it('forwards cache_read_input_tokens and cache_creation_input_tokens through to usageMetadata', () => { it('forwards cache_read_input_tokens and cache_creation_input_tokens through to usageMetadata', () => {
// A real mid-conversation Anthropic response carries all three prompt // A real mid-conversation Anthropic response carries all three prompt
// buckets at once: `input_tokens` (non-cached tail), // buckets at once: `input_tokens` (non-cached tail),
@@ -571,6 +571,12 @@ export class AnthropicContentConverter {
if (finishReason) { if (finishReason) {
candidate.finishReason = finishReason; candidate.finishReason = finishReason;
} }
const finishMessage = this.mapAnthropicStopDetailsToFinishMessage(
(response as Anthropic.Message & { stop_details?: unknown }).stop_details,
);
if (finishMessage) {
candidate.finishMessage = finishMessage;
}
llmResponse.candidates = [candidate]; llmResponse.candidates = [candidate];
llmResponse.responseId = response.id; llmResponse.responseId = response.id;
@@ -1017,6 +1023,34 @@ export class AnthropicContentConverter {
return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED; return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED;
} }
mapAnthropicStopDetailsToFinishMessage(
stopDetails?: unknown,
): string | undefined {
if (!stopDetails || typeof stopDetails !== 'object') return undefined;
const details = stopDetails as Record<string, unknown>;
if (details['type'] !== 'refusal') return undefined;
const messages: Record<string, string> = {
cyber: 'Response refused due to cyber-related safety policy.',
bio: 'Response refused due to biological safety policy.',
frontier_llm:
'Response refused due to frontier model development policy.',
reasoning_extraction:
'Response refused because it requested hidden reasoning.',
general_harms: 'Response refused due to a general safety policy.',
};
const category = details['category'];
if (typeof category !== 'string') {
return 'Response refused due to safety reasons.';
}
// `category` is provider text: an own-property check keeps `__proto__` /
// `constructor` on the unknown-category fallback instead of returning an
// object that would render as `[object Object]`.
return Object.prototype.hasOwnProperty.call(messages, category)
? messages[category]
: 'Response refused due to safety reasons.';
}
private isContentObject( private isContentObject(
content: unknown, content: unknown,
): content is { role: string; parts: Part[] } { ): content is { role: string; parts: Part[] } {
+47
View File
@@ -30,6 +30,7 @@ import type {
} from '@google/genai'; } from '@google/genai';
import { ApiError } from '@google/genai'; import { ApiError } from '@google/genai';
import { AuthType, type ContentGenerator } from './contentGenerator.js'; import { AuthType, type ContentGenerator } from './contentGenerator.js';
import { FinishReason } from './genai-compat.js';
import { getPlanModeSystemReminder } from './prompts.js'; import { getPlanModeSystemReminder } from './prompts.js';
import { import {
LlmChat, LlmChat,
@@ -9697,6 +9698,52 @@ describe('LlmChat', async () => {
expect(historyText).toBe(recordedText(record)); expect(historyText).toBe(recordedText(record));
}); });
it('preserves finishMessage on the deferred tool-result finish chunk', async () => {
vi.useFakeTimers();
chat.setHistory([
userText('read the file'),
content(
'model',
fnCall('read_file', { path: '/tmp/x' }, 'call_refusal'),
),
]);
mockStreamsOnce(
streamOf(textChunk('Request refused.'), {
candidates: [
{
finishReason: FinishReason.SAFETY,
finishMessage:
'Response refused due to cyber-related safety policy.',
},
],
} as GenerateContentResponse),
);
const events = await collectStreamWithFakeTimers(
await sendToolResult(
'call_refusal',
'prompt-deferred-finish-message',
),
10_000,
);
const finish = events
.filter((event) => event.type === StreamEventType.CHUNK)
.map(
(event) =>
(event as { value: GenerateContentResponse }).value
.candidates?.[0],
)
.find((candidate) => candidate?.finishReason);
expect(finish).toEqual(
expect.objectContaining({
finishReason: FinishReason.SAFETY,
finishMessage:
'Response refused due to cyber-related safety policy.',
}),
);
});
/** /**
* 'write a game': attempt 1 is cut after 'Here is the game: ', attempt 2 * 'write a game': attempt 1 is cut after 'Here is the game: ', attempt 2
* yields only `close` and dies from `error`; `rest` (if given) answers. * yields only `close` and dies from `error`; `rest` (if given) answers.
+13 -1
View File
@@ -7011,6 +7011,7 @@ export class LlmChat {
currentUserTurn?.role === 'user' && currentUserTurn?.role === 'user' &&
currentUserTurn.parts?.some((part) => part.functionResponse) === true; currentUserTurn.parts?.some((part) => part.functionResponse) === true;
let deferredFinishReason: FinishReason | undefined; let deferredFinishReason: FinishReason | undefined;
let deferredFinishMessage: string | undefined;
// Captured if the upstream stream throws mid-iteration (typical on weak // Captured if the upstream stream throws mid-iteration (typical on weak
// networks: SSE drops between `content_block_stop` of a tool_use and the // networks: SSE drops between `content_block_stop` of a tool_use and the
// terminal `message_stop`). We still build / record / push a partial // terminal `message_stop`). We still build / record / push a partial
@@ -7230,7 +7231,11 @@ export class LlmChat {
for (const candidate of chunk.candidates ?? []) { for (const candidate of chunk.candidates ?? []) {
if (candidate.finishReason) { if (candidate.finishReason) {
deferredFinishReason ??= candidate.finishReason; deferredFinishReason ??= candidate.finishReason;
deferredFinishMessage ??= candidate.finishMessage;
delete candidate.finishReason; delete candidate.finishReason;
// The message is replayed with the deferred reason at the end of
// the stream; keeping it here would ship a notice with no reason.
delete candidate.finishMessage;
} }
} }
} }
@@ -7867,7 +7872,14 @@ export class LlmChat {
abortSignal?.throwIfAborted(); abortSignal?.throwIfAborted();
if (deferredFinishReason) { if (deferredFinishReason) {
yield { yield {
candidates: [{ finishReason: deferredFinishReason }], candidates: [
{
finishReason: deferredFinishReason,
...(deferredFinishMessage
? { finishMessage: deferredFinishMessage }
: {}),
},
],
usageMetadata, usageMetadata,
} as GenerateContentResponse; } as GenerateContentResponse;
} }
+22
View File
@@ -926,6 +926,28 @@ describe('Turn', () => {
]); ]);
}); });
it('should include a provider finish message', async () => {
const events = await run([
textChunk('Content blocked', {
finishReason: 'SAFETY',
finishMessage: 'Response refused due to cyber-related safety policy.',
}),
]);
expect(events).toEqual([
contentEvent('Content blocked'),
{
type: LlmEventType.Finished,
value: {
reason: 'SAFETY',
usageMetadata: undefined,
finishMessage:
'Response refused due to cyber-related safety policy.',
},
},
]);
});
it('should yield finished event with undefined reason when there is no finish reason', async () => { it('should yield finished event with undefined reason when there is no finish reason', async () => {
const events = await run([textChunk('Response without finish reason')]); const events = await run([textChunk('Response without finish reason')]);
+3
View File
@@ -163,6 +163,7 @@ export interface SessionTokenLimitExceededValue {
export interface LlmFinishedEventValue { export interface LlmFinishedEventValue {
reason: FinishReason | undefined; reason: FinishReason | undefined;
usageMetadata: GenerateContentResponseUsageMetadata | undefined; usageMetadata: GenerateContentResponseUsageMetadata | undefined;
finishMessage?: string;
} }
/** /**
@@ -925,6 +926,7 @@ export class Turn {
// Check if response was truncated or stopped for various reasons // Check if response was truncated or stopped for various reasons
const finishReason = resp.candidates?.[0]?.finishReason; const finishReason = resp.candidates?.[0]?.finishReason;
const finishMessage = resp.candidates?.[0]?.finishMessage;
// This is the key change: Only yield 'Finished' if there is a finishReason. // This is the key change: Only yield 'Finished' if there is a finishReason.
if (finishReason) { if (finishReason) {
@@ -950,6 +952,7 @@ export class Turn {
value: { value: {
reason: finishReason, reason: finishReason,
usageMetadata: resp.usageMetadata, usageMetadata: resp.usageMetadata,
...(finishMessage ? { finishMessage } : {}),
}, },
}; };
} }