mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: show concise filtered response errors (#456)
This commit is contained in:
parent
f8940ebf26
commit
3a98713050
11 changed files with 277 additions and 9 deletions
7
.changeset/filtered-empty-response-errors.md
Normal file
7
.changeset/filtered-empty-response-errors.md
Normal 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.
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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...' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue