From 3a987130500fe5b403b696850165735c7d0ee076 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 12:48:35 +0800 Subject: [PATCH] fix: show concise filtered response errors (#456) --- .changeset/filtered-empty-response-errors.md | 7 +++ .../tui/controllers/session-event-handler.ts | 3 +- apps/kimi-code/src/tui/utils/event-payload.ts | 41 +++++++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 35 ++++++++++++ .../test/tui/utils/event-payload.test.ts | 41 ++++++++++++++ packages/agent-core/src/errors/serialize.ts | 14 +++++ packages/agent-core/test/agent/turn.test.ts | 55 +++++++++++++++++++ packages/kosong/src/errors.ts | 15 ++++- packages/kosong/src/generate.ts | 29 +++++++++- packages/kosong/test/errors.test.ts | 12 ++++ packages/kosong/test/generate.test.ts | 34 +++++++++++- 11 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 .changeset/filtered-empty-response-errors.md diff --git a/.changeset/filtered-empty-response-errors.md b/.changeset/filtered-empty-response-errors.md new file mode 100644 index 000000000..772d4db60 --- /dev/null +++ b/.changeset/filtered-empty-response-errors.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Show concise provider filtering errors when responses are blocked before visible output. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 76b46599f..3c80f5ce5 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -44,6 +44,7 @@ import { } from '../constant/kimi-tui'; import { argsRecord, + formatErrorPayload, formatErrorMessage, isTodoItemShape, serializeToolResultOutput, @@ -730,7 +731,7 @@ export class SessionEventHandler { this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); return; } - this.host.showError(`[${event.code}] ${event.message}`); + this.host.showError(formatErrorPayload(event)); const sessionId = this.host.state.appState.sessionId; if (sessionId.length > 0) { this.host.showStatus(errorReportHintLine()); diff --git a/apps/kimi-code/src/tui/utils/event-payload.ts b/apps/kimi-code/src/tui/utils/event-payload.ts index 51de71f80..2508996ea 100644 --- a/apps/kimi-code/src/tui/utils/event-payload.ts +++ b/apps/kimi-code/src/tui/utils/event-payload.ts @@ -1,4 +1,7 @@ -import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; +import { + isKimiError, + type KimiErrorPayload, +} from '@moonshot-ai/kimi-code-sdk'; import { STREAMING_ARGS_FIELD_RE, @@ -90,10 +93,44 @@ export function isTodoItemShape( } export function formatErrorMessage(error: unknown): string { - if (isKimiError(error)) return `[${error.code}] ${error.message}`; + if (isKimiError(error)) { + return formatErrorPayload({ + code: error.code, + message: error.message, + details: error.details, + }); + } return error instanceof Error ? error.message : String(error); } +export function formatErrorPayload( + error: Pick, +): string { + const filteredMessage = formatProviderFilteredMessage(error.details); + if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`; + return `[${error.code}] ${error.message}`; +} + +function formatProviderFilteredMessage( + details: Record | undefined, +): string | undefined { + const finishReason = stringDetail(details, 'finishReason'); + const rawFinishReason = stringDetail(details, 'rawFinishReason'); + if (finishReason !== 'filtered' && rawFinishReason !== 'content_filter') return undefined; + + const normalizedFinishReason = finishReason ?? 'filtered'; + const raw = rawFinishReason === undefined ? '' : `, rawFinishReason=${rawFinishReason}`; + return `Provider filtered the response before visible output (finishReason=${normalizedFinishReason}${raw}).`; +} + +function stringDetail( + details: Record | undefined, + key: string, +): string | undefined { + const value = details?.[key]; + return typeof value === 'string' ? value : undefined; +} + export function stringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 0063ef3f2..f9d20f7ed 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -2137,6 +2137,41 @@ command = "vim" expect(transcript).not.toContain('kimi export'); }); + it('shows concise provider filter text for filtered session errors', async () => { + const { driver } = await makeDriver(); + const verboseMessage = + 'The API returned a response containing only thinking content without any text or tool calls. ' + + 'This usually indicates the stream was interrupted or the output token budget was exhausted ' + + 'during reasoning. Provider stop details: finishReason=filtered, rawFinishReason=content_filter. ' + + 'The provider filtered the response before visible output was emitted. Provider: example-provider, model: example-model'; + + driver.sessionEventHandler.handleEvent( + { + type: 'error', + agentId: 'main', + sessionId: 'ses-1', + code: 'provider.api_error', + message: verboseMessage, + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + retryable: true, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain( + 'Error: [provider.api_error] Provider filtered the response before visible output', + ); + expect(transcript).toContain('finishReason=filtered'); + expect(transcript).toContain('rawFinishReason=content_filter'); + expect(transcript).not.toContain('only thinking content'); + expect(transcript).not.toContain('token budget'); + expect(transcript).not.toContain('stream was interrupted'); + }); + it('skips the /export-debug-zip hint when no active session id is set', async () => { const { driver } = await makeDriver(); driver.state.appState.sessionId = ''; diff --git a/apps/kimi-code/test/tui/utils/event-payload.test.ts b/apps/kimi-code/test/tui/utils/event-payload.test.ts index f5d9f04c2..1f8ff22f2 100644 --- a/apps/kimi-code/test/tui/utils/event-payload.test.ts +++ b/apps/kimi-code/test/tui/utils/event-payload.test.ts @@ -1,8 +1,11 @@ +import { ErrorCodes, KimiError } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it } from 'vitest'; import { STREAMING_ARGS_PREVIEW_MAX_CHARS } from '#/tui/constant/streaming'; import { appendStreamingArgsPreview, + formatErrorMessage, + formatErrorPayload, parseStreamingArgs, } from '#/tui/utils/event-payload'; @@ -28,3 +31,41 @@ describe('streaming tool argument payload helpers', () => { expect(parseStreamingArgs(oversized)).toEqual({ command: 'echo ok' }); }); }); + +describe('error payload formatting', () => { + const filteredThinkOnlyMessage = + 'The API returned a response containing only thinking content without any text or tool calls. ' + + 'This usually indicates the stream was interrupted or the output token budget was exhausted ' + + 'during reasoning. Provider stop details: finishReason=filtered, rawFinishReason=content_filter. ' + + 'The provider filtered the response before visible output was emitted. Provider: example-provider, model: example-model'; + const conciseFilteredMessage = + '[provider.api_error] Provider filtered the response before visible output ' + + '(finishReason=filtered, rawFinishReason=content_filter).'; + + it('shows concise provider filter text from structured error payload details', () => { + const formatted = formatErrorPayload({ + code: ErrorCodes.PROVIDER_API_ERROR, + message: filteredThinkOnlyMessage, + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + }); + + expect(formatted).toBe(conciseFilteredMessage); + expect(formatted).not.toContain('only thinking content'); + expect(formatted).not.toContain('token budget'); + expect(formatted).not.toContain('stream was interrupted'); + }); + + it('shows concise provider filter text from KimiError details', () => { + const error = new KimiError(ErrorCodes.PROVIDER_API_ERROR, filteredThinkOnlyMessage, { + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + }); + + expect(formatErrorMessage(error)).toBe(conciseFilteredMessage); + }); +}); diff --git a/packages/agent-core/src/errors/serialize.ts b/packages/agent-core/src/errors/serialize.ts index 14f594753..bbad58db1 100644 --- a/packages/agent-core/src/errors/serialize.ts +++ b/packages/agent-core/src/errors/serialize.ts @@ -1,5 +1,6 @@ import { APIConnectionError, + APIEmptyResponseError, APIStatusError, APITimeoutError, ChatProviderError, @@ -102,6 +103,19 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload { }; } + if (error instanceof APIEmptyResponseError) { + return { + code: ErrorCodes.PROVIDER_API_ERROR, + message: error.message, + name: error.name, + details: { + finishReason: error.finishReason, + rawFinishReason: error.rawFinishReason, + }, + retryable: KIMI_ERROR_INFO[ErrorCodes.PROVIDER_API_ERROR].retryable, + }; + } + if (error instanceof ChatProviderError) { return { code: ErrorCodes.PROVIDER_API_ERROR, diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 004715e56..18ea474be 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -248,6 +248,61 @@ describe('Agent turn flow', () => { await ctx.expectResumeMatches(); }); + it('includes provider finish reason details on empty response failures', async () => { + const generate: GenerateFn = async () => { + throw new APIEmptyResponseError( + 'The API returned a response containing only thinking content without any text or tool calls. ' + + 'Provider stop details: finishReason=filtered, rawFinishReason=content_filter.', + { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + ); + }; + const ctx = testAgent({ + generate, + ...singleAttemptAgentOptions(), + }); + ctx.configure(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger filtered response' }] }); + const events = await ctx.untilTurnEnd(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'turn.ended', + args: expect.objectContaining({ + reason: 'failed', + error: expect.objectContaining({ + code: 'provider.api_error', + name: 'APIEmptyResponseError', + details: expect.objectContaining({ + finishReason: 'filtered', + rawFinishReason: 'content_filter', + turnId: 0, + }), + }), + }), + }), + ); + expect(ctx.newEvents()).toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'error', + args: expect.objectContaining({ + code: 'provider.api_error', + name: 'APIEmptyResponseError', + details: expect.objectContaining({ + finishReason: 'filtered', + rawFinishReason: 'content_filter', + turnId: 0, + }), + }), + }), + ); + }); + it('emits a friendly model.not_configured error when no model is configured', async () => { const ctx = testAgent(); diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 6804fe7ea..686e093f3 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -1,3 +1,5 @@ +import type { FinishReason } from './provider'; + /** * Base error for all chat provider errors. */ @@ -58,9 +60,20 @@ export class APIContextOverflowError extends APIStatusError { * The API returned an empty response (no content, no tool calls). */ export class APIEmptyResponseError extends ChatProviderError { - constructor(message: string) { + readonly finishReason: FinishReason | null; + readonly rawFinishReason: string | null; + + constructor( + message: string, + options: { + readonly finishReason?: FinishReason | null; + readonly rawFinishReason?: string | null; + } = {}, + ) { super(message); this.name = 'APIEmptyResponseError'; + this.finishReason = options.finishReason ?? null; + this.rawFinishReason = options.rawFinishReason ?? null; } } diff --git a/packages/kosong/src/generate.ts b/packages/kosong/src/generate.ts index cb0df9e4a..d17fef39c 100644 --- a/packages/kosong/src/generate.ts +++ b/packages/kosong/src/generate.ts @@ -165,7 +165,13 @@ export async function generate( } if (message.content.length === 0 && message.toolCalls.length === 0) { throw new APIEmptyResponseError( - `The API returned an empty response (no content, no tool calls). Provider: ${provider.name}, model: ${provider.modelName}`, + 'The API returned an empty response (no content, no tool calls).' + + formatFinishReasonHint(stream) + + ` Provider: ${provider.name}, model: ${provider.modelName}`, + { + finishReason: stream.finishReason, + rawFinishReason: stream.rawFinishReason, + }, ); } @@ -179,7 +185,13 @@ export async function generate( 'The API returned a response containing only thinking content ' + 'without any text or tool calls. This usually indicates the ' + 'stream was interrupted or the output token budget was exhausted ' + - `during reasoning. Provider: ${provider.name}, model: ${provider.modelName}`, + 'during reasoning.' + + formatFinishReasonHint(stream) + + ` Provider: ${provider.name}, model: ${provider.modelName}`, + { + finishReason: stream.finishReason, + rawFinishReason: stream.rawFinishReason, + }, ); } @@ -276,6 +288,19 @@ function flushPart( // ToolCallPart: orphaned delta — silently ignore. } +function formatFinishReasonHint(stream: StreamedMessage): string { + if (stream.finishReason === null && stream.rawFinishReason === null) return ''; + + const raw = + stream.rawFinishReason === null ? '' : `, rawFinishReason=${stream.rawFinishReason}`; + const filteredHint = + stream.finishReason === 'filtered' + ? ' The provider filtered the response before visible output was emitted.' + : ''; + + return ` Provider stop details: finishReason=${stream.finishReason ?? 'unknown'}${raw}.${filteredHint}`; +} + /** * Produce a shallow-ish copy of a StreamedMessagePart. * diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 70d5ec852..5e4c05bba 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -71,6 +71,18 @@ describe('APIEmptyResponseError', () => { expect(err).toBeInstanceOf(Error); expect(err.name).toBe('APIEmptyResponseError'); expect(err.message).toBe('empty response'); + expect(err.finishReason).toBeNull(); + expect(err.rawFinishReason).toBeNull(); + }); + + it('preserves provider finish reason details', () => { + const err = new APIEmptyResponseError('empty response', { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }); + + expect(err.finishReason).toBe('filtered'); + expect(err.rawFinishReason).toBe('content_filter'); }); }); diff --git a/packages/kosong/test/generate.test.ts b/packages/kosong/test/generate.test.ts index 627a475a6..6c2777947 100644 --- a/packages/kosong/test/generate.test.ts +++ b/packages/kosong/test/generate.test.ts @@ -7,7 +7,12 @@ import type { TokenUsage } from '#/usage'; import { describe, expect, it, vi } from 'vitest'; function createMockStream( parts: StreamedMessagePart[], - opts?: { id?: string; usage?: TokenUsage }, + opts?: { + id?: string; + usage?: TokenUsage; + finishReason?: StreamedMessage['finishReason']; + rawFinishReason?: string | null; + }, ): StreamedMessage { return { get id(): string | null { @@ -16,8 +21,8 @@ function createMockStream( get usage(): TokenUsage | null { return opts?.usage ?? null; }, - finishReason: null, - rawFinishReason: null, + finishReason: opts?.finishReason ?? null, + rawFinishReason: opts?.rawFinishReason ?? null, async *[Symbol.asyncIterator](): AsyncIterator { for (const part of parts) { yield part; @@ -201,6 +206,29 @@ describe('generate()', () => { await expect(generate(provider, '', [], [])).rejects.toThrow(/only thinking content/); }); + it('includes finish reason details on think-only APIEmptyResponseError', async () => { + const stream = createMockStream( + [{ type: 'think', think: 'Deep thinking about the problem...' }], + { finishReason: 'filtered', rawFinishReason: 'content_filter' }, + ); + const provider = createMockProvider(stream); + + let caught: unknown; + try { + await generate(provider, '', [], []); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(APIEmptyResponseError); + const err = caught as APIEmptyResponseError; + expect(err.finishReason).toBe('filtered'); + expect(err.rawFinishReason).toBe('content_filter'); + expect(err.message).toContain('finishReason=filtered'); + expect(err.message).toContain('rawFinishReason=content_filter'); + expect(err.message).toContain('provider filtered the response'); + }); + it('throws APIEmptyResponseError for think + empty/whitespace text', async () => { const stream = createMockStream([ { type: 'think', think: 'Thinking...' },