kimi-code/packages/agent-core/test/loop/events.e2e.test.ts
liruifengv e9db9cafcf
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
feat: record model response id in wire logs (#1349)
* feat: record model response id in wire logs

* chore: include sdk in response id changeset

* chore: include agent-core in response id changeset
2026-07-03 17:15:04 +08:00

189 lines
6.3 KiB
TypeScript

/**
* LoopEventDispatcher live event contract — fire-and-forget listener errors must
* never reach the loop.
*/
import { describe, expect, it } from 'vitest';
import type { LoopEvent, LoopLiveEventEmitter } from '../../src/loop/index';
import { CollectingSink } from './fixtures/collecting-sink';
import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
import { runTurn } from './fixtures/helpers';
import { EchoTool, markReadFileAccesses, ProgressTool } from './fixtures/tools';
describe('runTurn — LoopEventDispatcher live event containment', () => {
it('contains synchronous emit() throws', async () => {
const sink = new CollectingSink({ errorMode: { kind: 'sync-throw' } });
const { result } = await runTurn({
emitLiveEvent: sink.emit,
responses: [makeEndTurnResponse('ok')],
});
// The loop still completes the turn.
expect(result.stopReason).toBe('end_turn');
});
it('contains async-rejected emit() returns', async () => {
const sink = new CollectingSink({ errorMode: { kind: 'async-reject' } });
const { result } = await runTurn({
emitLiveEvent: sink.emit,
responses: [makeEndTurnResponse('ok')],
});
expect(result.stopReason).toBe('end_turn');
// Each emit is recorded even though the listener returned a rejected
// promise; LoopEventDispatcher attaches a terminal catch.
expect(sink.events.length).toBeGreaterThanOrEqual(2);
});
it('survives a sink that throws on EVERY emit across a full multi-step turn', async () => {
const sink = new CollectingSink({ errorMode: { kind: 'every-call-throws' } });
const echo = new EchoTool();
const progress = new ProgressTool();
const { result, context } = await runTurn({
emitLiveEvent: sink.emit,
tools: [echo, progress],
responses: [
makeToolUseResponse([
makeToolCall('echo', { text: '1' }, 'a'),
makeToolCall('progress', {}, 'b'),
]),
makeEndTurnResponse('done'),
],
});
// Despite every emit throwing, the turn still converges and writes
// its transcript records.
expect(result.stopReason).toBe('end_turn');
expect(context.stepBegins().length).toBe(2);
expect(context.stepEnds().length).toBe(2);
// The tool was still executed
expect(echo.calls.length).toBe(1);
expect(progress.calls.length).toBe(1);
});
it('allows host-owned fan-out through a single emitter function', async () => {
const a = new CollectingSink({ id: 'a' });
const b = new CollectingSink({ id: 'b' });
const { result } = await runTurn({
emitLiveEvent: (event) => {
a.emit(event);
b.emit(event);
},
responses: [makeEndTurnResponse('ok')],
});
expect(result.stopReason).toBe('end_turn');
expect(a.typesIn()).toEqual(b.typesIn());
expect(a.events.length).toBeGreaterThan(0);
});
it('a misbehaving sink does not starve the others', async () => {
const broken = new CollectingSink({
id: 'broken',
errorMode: { kind: 'every-call-throws' },
});
const good = new CollectingSink({ id: 'good' });
const { result } = await runTurn({
emitLiveEvent: (event) => {
try {
broken.emit(event);
} catch {
// host-owned fan-out contains individual listener failure
}
good.emit(event);
},
responses: [makeEndTurnResponse('ok')],
});
expect(result.stopReason).toBe('end_turn');
// Good sink received the events; ordering matches the documented set.
expect(good.count('step.begin')).toBe(1);
expect(good.count('step.end')).toBe(1);
});
it('emits a documented full event sequence for one tool-bearing turn', async () => {
const echo = markReadFileAccesses(new EchoTool());
const progress = markReadFileAccesses(
new ProgressTool([{ kind: 'progress', percent: 50 }]),
);
const { sink } = await runTurn({
tools: [echo, progress],
responses: [
makeToolUseResponse([
makeToolCall('echo', { text: 'hi' }, 'tc-1'),
makeToolCall('progress', {}, 'tc-2'),
]),
makeEndTurnResponse('done', { inputOther: 1, output: 1 }),
],
});
const types = sink.typesIn();
// Required milestones in order
const order: LoopEvent['type'][] = [
'step.begin',
'tool.call',
'tool.call',
'tool.result',
'tool.result',
'step.end',
'step.begin',
'step.end',
];
// Every required type appears in the right relative order
let fromIdx = 0;
for (const expected of order) {
const found = types.indexOf(expected, fromIdx);
expect(
found,
`missing ${expected} starting at ${String(fromIdx)} of [${types.join(',')}]`,
).toBeGreaterThanOrEqual(fromIdx);
fromIdx = found + 1;
}
// tool.progress fired between tool.call and tool.result
expect(sink.byType('tool.progress').length).toBe(1);
});
it('LoopEvent payload carries the documented fields', async () => {
const echo = new EchoTool();
const { sink } = await runTurn({
tools: [echo],
responses: [
makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-99')]),
makeEndTurnResponse('done'),
],
});
const tc = sink.byType('tool.call')[0];
expect(tc).toBeDefined();
expect(tc?.toolCallId).toBe('tc-99');
expect(tc?.name).toBe('echo');
expect(tc?.args).toEqual({ text: 'hi' });
const tr = sink.byType('tool.result')[0];
expect(tr?.toolCallId).toBe('tc-99');
expect(typeof tr?.result.output).toBe('string');
});
it('records the provider response id on step.end', async () => {
const { context } = await runTurn({
responses: [
{
...makeEndTurnResponse('ok'),
messageId: 'chatcmpl-test',
},
],
});
expect(context.stepEnds()[0]?.messageId).toBe('chatcmpl-test');
});
it('accepts a custom emitter function', async () => {
class StrictCollector {
readonly events: LoopEvent[] = [];
readonly emit: LoopLiveEventEmitter = (event) => {
this.events.push(event);
};
}
const custom = new StrictCollector();
const { result } = await runTurn({
emitLiveEvent: custom.emit,
responses: [makeEndTurnResponse('ok')],
});
expect(result.stopReason).toBe('end_turn');
expect(custom.events.length).toBeGreaterThan(0);
});
});