diff --git a/packages/cli/src/ui/hooks/use-llm-stream.test.tsx b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx index b4bb7705381..397415dbd4e 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.test.tsx +++ b/packages/cli/src/ui/hooks/use-llm-stream.test.tsx @@ -20107,6 +20107,35 @@ describe('useLlmStream', () => { 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 () => { // Setup mock to return a stream with FINISH_REASON_UNSPECIFIED mockSendMessageStream.mockReturnValue( diff --git a/packages/cli/src/ui/hooks/use-llm-stream.ts b/packages/cli/src/ui/hooks/use-llm-stream.ts index dd07b01c0aa..15671ef502c 100644 --- a/packages/cli/src/ui/hooks/use-llm-stream.ts +++ b/packages/cli/src/ui/hooks/use-llm-stream.ts @@ -3002,7 +3002,13 @@ export const useLlmStream = ( '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) { addItem( { diff --git a/packages/cli/src/ui/opentui/event-adapter.test.ts b/packages/cli/src/ui/opentui/event-adapter.test.ts index 3e9a0c39f9c..4446088b9e6 100644 --- a/packages/cli/src/ui/opentui/event-adapter.test.ts +++ b/packages/cli/src/ui/opentui/event-adapter.test.ts @@ -278,6 +278,42 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => { 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', () => { diff --git a/packages/cli/src/ui/opentui/event-adapter.ts b/packages/cli/src/ui/opentui/event-adapter.ts index 2f728bb611f..daeb603a5d4 100644 --- a/packages/cli/src/ui/opentui/event-adapter.ts +++ b/packages/cli/src/ui/opentui/event-adapter.ts @@ -953,8 +953,16 @@ export function createEventMapper( out.push({ type: 'retry-countdown-clear' }); // ink parity: handleFinishedEvent adds `{type: 'info'}` for // non-STOP finish reasons. - const reason = (ev.value as { reason?: string } | undefined)?.reason; - const message = reason ? FINISH_REASON_NOTICES[reason] : undefined; + const value = ev.value as + | { 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}` }); // Segment marker only — the turn settles when the live generator // returns (backend emits `done`), NOT here: `finished` arrives diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index d8b1891321f..03b2d0a4e50 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -191,9 +191,10 @@ const messageDelta = ( stop_reason: string, output_tokens: number, usage: object = {}, + stop_details?: object, ): SseEvent => ({ type: 'message_delta', - delta: { stop_reason }, + delta: { stop_reason, ...(stop_details ? { stop_details } : {}) }, usage: { output_tokens, ...usage }, }); // Yields `events`, then fails with `error`. @@ -2452,6 +2453,27 @@ describe('AnthropicContentGenerator', () => { 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([ [ 'rejects an open tool-use block after assistant payload at end of stream', diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index cdd3d9d2255..08dc9f9963d 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -1414,6 +1414,7 @@ export class AnthropicContentGenerator implements ContentGenerator { let completionTokens = 0; let completionTokensReported = false; let finishReason: string | undefined; + let finishMessage: string | undefined; const blocks = new Map(); const deferredToolCalls: GenerateContentResponse[] = []; @@ -1620,6 +1621,10 @@ export class AnthropicContentGenerator implements ContentGenerator { } case 'message_delta': { const stopReasonValue = event.delta.stop_reason; + finishMessage ??= + this.converter.mapAnthropicStopDetailsToFinishMessage( + (event.delta as { stop_details?: unknown }).stop_details, + ); if (stopReasonValue) { finishReason = stopReasonValue; const hasOpenToolCall = [...blocks.values()].some( @@ -1719,6 +1724,7 @@ export class AnthropicContentGenerator implements ContentGenerator { cacheReadTokensReported: cachedTokensReported, cacheCreationTokensReported, }), + finishMessage, ); collectedResponses.push(chunk); yield chunk; @@ -1748,6 +1754,7 @@ export class AnthropicContentGenerator implements ContentGenerator { cacheReadTokensReported: cachedTokensReported, cacheCreationTokensReported, }), + finishMessage, ); collectedResponses.push(chunk); yield chunk; @@ -1895,6 +1902,7 @@ export class AnthropicContentGenerator implements ContentGenerator { model?: string, finishReason?: string, usageMetadata?: GenerateContentResponseUsageMetadata, + finishMessage?: string, ): GenerateContentResponse { const response = new GenerateContentResponse(); response.responseId = responseId; @@ -1916,6 +1924,7 @@ export class AnthropicContentGenerator implements ContentGenerator { index: 0, safetyRatings: [], ...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}), + ...(finishMessage ? { finishMessage } : {}), }, ]; diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 8a9bef56e49..3d53aa02bd3 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -1740,6 +1740,58 @@ describe('AnthropicContentConverter', () => { 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', () => { // A real mid-conversation Anthropic response carries all three prompt // buckets at once: `input_tokens` (non-cached tail), diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index cdcf15907ef..1297e327466 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -571,6 +571,12 @@ export class AnthropicContentConverter { if (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.responseId = response.id; @@ -1017,6 +1023,34 @@ export class AnthropicContentConverter { return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED; } + mapAnthropicStopDetailsToFinishMessage( + stopDetails?: unknown, + ): string | undefined { + if (!stopDetails || typeof stopDetails !== 'object') return undefined; + const details = stopDetails as Record; + if (details['type'] !== 'refusal') return undefined; + + const messages: Record = { + 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( content: unknown, ): content is { role: string; parts: Part[] } { diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index 28b50f6c93a..4591651adc8 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -30,6 +30,7 @@ import type { } from '@google/genai'; import { ApiError } from '@google/genai'; import { AuthType, type ContentGenerator } from './contentGenerator.js'; +import { FinishReason } from './genai-compat.js'; import { getPlanModeSystemReminder } from './prompts.js'; import { LlmChat, @@ -9697,6 +9698,52 @@ describe('LlmChat', async () => { 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 * yields only `close` and dies from `error`; `rest` (if given) answers. diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts index 3960c737f53..3bde823546c 100644 --- a/packages/core/src/core/llm-chat.ts +++ b/packages/core/src/core/llm-chat.ts @@ -7011,6 +7011,7 @@ export class LlmChat { currentUserTurn?.role === 'user' && currentUserTurn.parts?.some((part) => part.functionResponse) === true; let deferredFinishReason: FinishReason | undefined; + let deferredFinishMessage: string | undefined; // Captured if the upstream stream throws mid-iteration (typical on weak // networks: SSE drops between `content_block_stop` of a tool_use and the // terminal `message_stop`). We still build / record / push a partial @@ -7230,7 +7231,11 @@ export class LlmChat { for (const candidate of chunk.candidates ?? []) { if (candidate.finishReason) { deferredFinishReason ??= candidate.finishReason; + deferredFinishMessage ??= candidate.finishMessage; 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(); if (deferredFinishReason) { yield { - candidates: [{ finishReason: deferredFinishReason }], + candidates: [ + { + finishReason: deferredFinishReason, + ...(deferredFinishMessage + ? { finishMessage: deferredFinishMessage } + : {}), + }, + ], usageMetadata, } as GenerateContentResponse; } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 9475e8176d9..6d45334845a 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -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 () => { const events = await run([textChunk('Response without finish reason')]); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 08adf7c7cd3..03f79d53a15 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -163,6 +163,7 @@ export interface SessionTokenLimitExceededValue { export interface LlmFinishedEventValue { reason: FinishReason | undefined; usageMetadata: GenerateContentResponseUsageMetadata | undefined; + finishMessage?: string; } /** @@ -925,6 +926,7 @@ export class Turn { // Check if response was truncated or stopped for various reasons 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. if (finishReason) { @@ -950,6 +952,7 @@ export class Turn { value: { reason: finishReason, usageMetadata: resp.usageMetadata, + ...(finishMessage ? { finishMessage } : {}), }, }; }