mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-20 22:54:26 +00:00
Merge turnId/step into a single `turnStep` field ("0.1") and
attempt/maxAttempts into `attempt` ("2/3"), and drop the
messageCount/toolCallCount fields. The per-request `llm request`
line goes from up to 8 fields down to ~3; the `llm config` line
(including thinkingEffort, logged for all providers) is unchanged.
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
/**
|
|
* Error propagation contract.
|
|
*
|
|
* The loop never returns `'error'` or `'max_steps'` as a stopReason — those
|
|
* states surface only by throwing. AbortError-shaped exceptions converge
|
|
* to `stopReason='aborted'` and never throw to the caller. Every throw
|
|
* is preceded by exactly one `turn.interrupted` event whose `reason`
|
|
* names the cause.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { ErrorCodes, KimiError } from '../../src/errors';
|
|
import type { Logger, LogPayload } from '../../src/logging';
|
|
import type { LoopHooks } from '../../src/loop/index';
|
|
import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
|
|
import { runTurn, runTurnExpectingThrow } from './fixtures/helpers';
|
|
import { EchoTool } from './fixtures/tools';
|
|
|
|
interface CapturedLogEntry {
|
|
readonly level: 'error' | 'warn' | 'info' | 'debug';
|
|
readonly message: string;
|
|
readonly payload?: LogPayload;
|
|
}
|
|
|
|
describe('runTurn — error paths', () => {
|
|
it('rethrows non-abort LLM errors with turn.interrupted{reason:"error"}', async () => {
|
|
const llmError = new Error('upstream blew up');
|
|
const { error, sink, context } = await runTurnExpectingThrow({
|
|
responses: [makeEndTurnResponse('never')], // ignored: throw fires first
|
|
llmThrowOnIndex: { index: 0, error: llmError },
|
|
});
|
|
|
|
expect(error).toBe(llmError);
|
|
const interrupted = sink.byType('turn.interrupted');
|
|
expect(interrupted.length).toBe(1);
|
|
expect(interrupted[0]?.reason).toBe('error');
|
|
expect(interrupted[0]?.attemptedSteps).toBe(1);
|
|
expect(interrupted[0]?.activeStep).toBe(1);
|
|
// step.begin was opened but step.end was NOT
|
|
expect(context.stepBegins().length).toBe(1);
|
|
expect(context.stepEnds().length).toBe(0);
|
|
});
|
|
|
|
it('logs non-abort LLM request failures without request payloads or stacks', async () => {
|
|
const llmError = new Error('upstream blew up');
|
|
const { logger, entries } = captureLogs();
|
|
const { error } = await runTurnExpectingThrow({
|
|
responses: [makeEndTurnResponse('never')],
|
|
llmThrowOnIndex: { index: 0, error: llmError },
|
|
log: logger,
|
|
});
|
|
|
|
expect(error).toBe(llmError);
|
|
expect(entries).toEqual([
|
|
{
|
|
level: 'warn',
|
|
message: 'llm request failed',
|
|
payload: {
|
|
turnStep: 'turn-1.1',
|
|
attempt: '1/3',
|
|
model: 'fake-model',
|
|
errorName: 'Error',
|
|
errorMessage: 'upstream blew up',
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('does not log aborted LLM requests as failures', async () => {
|
|
const controller = new AbortController();
|
|
const { logger, entries } = captureLogs();
|
|
const { result } = await runTurn({
|
|
responses: [makeEndTurnResponse('never')],
|
|
llmAbortOnIndex: { index: 0, controller },
|
|
signal: controller.signal,
|
|
log: logger,
|
|
});
|
|
|
|
expect(result.stopReason).toBe('aborted');
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it('throws KimiError(loop.max_steps_exceeded) with turn.interrupted{reason:"max_steps"} before the throw', async () => {
|
|
const echo = new EchoTool();
|
|
const { error, sink } = await runTurnExpectingThrow({
|
|
maxSteps: 2,
|
|
tools: [echo],
|
|
responses: [
|
|
makeToolUseResponse([makeToolCall('echo', { text: '1' }, 'a')]),
|
|
makeToolUseResponse([makeToolCall('echo', { text: '2' }, 'b')]),
|
|
// never reached
|
|
],
|
|
});
|
|
|
|
expect(error).toBeInstanceOf(KimiError);
|
|
expect((error as KimiError).code).toBe(ErrorCodes.LOOP_MAX_STEPS_EXCEEDED);
|
|
const interrupted = sink.byType('turn.interrupted');
|
|
expect(interrupted.map((e) => e.reason)).toEqual(['max_steps']);
|
|
expect(interrupted[0]?.attemptedSteps).toBe(2);
|
|
expect(interrupted[0]?.activeStep).toBeUndefined();
|
|
});
|
|
|
|
it('rethrows non-abort hook errors via the same path', async () => {
|
|
const hookError = new Error('hook crashed');
|
|
const hooks: LoopHooks = {
|
|
beforeStep: async () => {
|
|
throw hookError;
|
|
},
|
|
};
|
|
const { error, sink } = await runTurnExpectingThrow({
|
|
hooks,
|
|
responses: [makeEndTurnResponse('never')],
|
|
});
|
|
|
|
expect(error).toBe(hookError);
|
|
const interrupted = sink.byType('turn.interrupted');
|
|
expect(interrupted.length).toBe(1);
|
|
expect(interrupted[0]?.reason).toBe('error');
|
|
});
|
|
|
|
it('AbortError thrown by a hook converges to stopReason="aborted" (no throw)', async () => {
|
|
const controller = new AbortController();
|
|
const hooks: LoopHooks = {
|
|
beforeStep: async () => {
|
|
controller.abort();
|
|
const err = new Error('aborted');
|
|
err.name = 'AbortError';
|
|
throw err;
|
|
},
|
|
};
|
|
const { result, sink } = await runTurn({
|
|
hooks,
|
|
responses: [makeEndTurnResponse('never')],
|
|
signal: controller.signal,
|
|
});
|
|
expect(result.stopReason).toBe('aborted');
|
|
const interrupted = sink.byType('turn.interrupted');
|
|
expect(interrupted.length).toBe(1);
|
|
expect(interrupted[0]?.reason).toBe('aborted');
|
|
});
|
|
|
|
it('emits turn.interrupted exactly once per failure (no duplicate emits)', async () => {
|
|
const { sink } = await runTurnExpectingThrow({
|
|
responses: [makeEndTurnResponse('never')],
|
|
llmThrowOnIndex: { index: 0, error: new Error('once') },
|
|
});
|
|
expect(sink.byType('turn.interrupted').length).toBe(1);
|
|
});
|
|
|
|
it('does NOT emit step.end when a step throws before sealing', async () => {
|
|
const { sink } = await runTurnExpectingThrow({
|
|
responses: [makeEndTurnResponse('never')],
|
|
llmThrowOnIndex: { index: 0, error: new Error('boom') },
|
|
});
|
|
// step.begin fires, step.end does not, turn.interrupted takes its place
|
|
expect(sink.count('step.begin')).toBe(1);
|
|
expect(sink.count('step.end')).toBe(0);
|
|
expect(sink.count('turn.interrupted')).toBe(1);
|
|
});
|
|
});
|
|
|
|
function captureLogs(): { readonly logger: Logger; readonly entries: CapturedLogEntry[] } {
|
|
const entries: CapturedLogEntry[] = [];
|
|
const logger: Logger = {
|
|
error: (message, payload) => {
|
|
entries.push({ level: 'error', message, payload });
|
|
},
|
|
warn: (message, payload) => {
|
|
entries.push({ level: 'warn', message, payload });
|
|
},
|
|
info: (message, payload) => {
|
|
entries.push({ level: 'info', message, payload });
|
|
},
|
|
debug: (message, payload) => {
|
|
entries.push({ level: 'debug', message, payload });
|
|
},
|
|
createChild: () => logger,
|
|
};
|
|
return { logger, entries };
|
|
}
|