fix: show concise filtered response errors (#456)

This commit is contained in:
_Kerman 2026-06-05 12:48:35 +08:00 committed by GitHub
parent f8940ebf26
commit 3a98713050
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 277 additions and 9 deletions

View file

@ -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.

View file

@ -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());

View file

@ -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<KimiErrorPayload, 'code' | 'message' | 'details'>,
): string {
const filteredMessage = formatProviderFilteredMessage(error.details);
if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`;
return `[${error.code}] ${error.message}`;
}
function formatProviderFilteredMessage(
details: Record<string, unknown> | 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<string, unknown> | 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;
}

View file

@ -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 = '';

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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.
*

View file

@ -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');
});
});

View file

@ -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<StreamedMessagePart> {
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...' },