kimi-code/packages/agent-core/test/loop/tool-call.e2e.test.ts
Kai 373abb02f0
Some checks are pending
CI / build (push) Waiting to run
CI / VSIX package audit (darwin-arm64) (push) Waiting to run
CI / VSIX package audit (all) (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / VSIX package audit (win32-x64) (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (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
fix(agent-core): record and close tool calls interrupted mid-stream (#1790)
When the provider stream breaks off mid-tool-call (pause_turn, engine
overload, token limit), the step ended with the partial tool call dropped
silently: never executed, never recorded. If the response carried no other
usable content, the persisted assistant message was effectively empty and
every subsequent request — including compaction — was rejected with
"assistant must not be empty" (HTTP 400), wedging the session.

Record each unexecuted call instead (arguments sanitized to {} when the
truncated JSON is unparseable) and immediately close it with a synthetic
interrupted error result. The exchange stays wire-valid, the history stays
truthful, and the model learns the calls never ran so it can re-issue them.
2026-07-16 21:12:00 +08:00

995 lines
34 KiB
TypeScript

/**
* Tool-call invariants observed end-to-end.
*
* The loop promises that every provider tool_call produces exactly one
* matching tool.result event/record, even on
* the rejected/error paths (tool not found, schema rejected, LLM-marked args
* error, execute throws). It also runs non-conflicting tool tasks in parallel
* while dispatching terminal events in provider order.
*/
import type { ContentPart } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { ToolAccesses } from '../../src/loop';
import type { Logger } from '../../src/logging';
import type { ExecutableTool, ExecutableToolResult, LoopHooks, ToolCall, ToolExecution } from '../../src/loop';
import { PathSecurityError } from '../../src/tools/policies/path-access';
import {
makeEndTurnResponse,
makeResponse,
makeTextParts,
makeThinkingParts,
makeToolCall,
makeToolUseResponse,
} from './fixtures/fake-llm';
import { runTurn } from './fixtures/helpers';
import {
ContentBlocksTool,
EchoTool,
FailingTool,
GatedTool,
markReadFileAccesses,
ProgressTool,
StrictArgsTool,
} from './fixtures/tools';
function expectTextOutput(output: unknown): string {
expect(typeof output).toBe('string');
return output as string;
}
async function contentBlockOutput(output: ContentPart[]): Promise<ContentPart[]> {
const blocks = new ContentBlocksTool({ output });
const { context } = await runTurn({
tools: [blocks],
responses: [
makeToolUseResponse([makeToolCall('blocks', {}, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
const payload = context.toolResults()[0]?.result;
expect(Array.isArray(payload?.output)).toBe(true);
return payload?.output as ContentPart[];
}
function waitOneMacrotask(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function makeTestLogger(): {
readonly log: Logger;
readonly entries: Array<{ readonly level: string; readonly message: string; readonly payload: unknown }>;
} {
const entries: Array<{ readonly level: string; readonly message: string; readonly payload: unknown }> = [];
const log: 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: () => log,
};
return { log, entries };
}
describe('runTurn — tool-call behaviour', () => {
it('routes a successful tool call through execute and emits paired events', async () => {
const echo = new EchoTool();
const { sink, context } = await runTurn({
tools: [echo],
responses: [
makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(echo.calls.length).toBe(1);
expect(echo.calls[0]?.id).toBe('tc-1');
// tool.call and tool.result are paired
expect(sink.byType('tool.call').map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(sink.byType('tool.result').map((e) => e.toolCallId)).toEqual(['tc-1']);
// appendToolResult was called with the tool's content as output
const trs = context.toolResults();
expect(trs.length).toBe(1);
expect(trs[0]?.toolCallId).toBe('tc-1');
expect(trs[0]?.result.output).toBe('hi');
expect(trs[0]?.result.isError).toBeUndefined();
});
it('preserves a tool result note through normalization into the recorded event', async () => {
const blocks = new ContentBlocksTool({
output: 'payload',
note: '<system>meta for the model</system>',
});
const { context } = await runTurn({
tools: [blocks],
responses: [
makeToolUseResponse([makeToolCall('blocks', {}, 'tc-note')]),
makeEndTurnResponse('done'),
],
});
const result = context.toolResults()[0]?.result;
expect(result?.output).toBe('payload');
// note is part of the persisted result contract (unlike stopTurn/message,
// which normalization drops before the record is written).
expect(result?.note).toBe('<system>meta for the model</system>');
});
it('enforces the note contract (string | undefined) at the normalization boundary', async () => {
// Tools are arbitrary JS: a malformed note (null, number, object, empty
// string) must never reach the record — everything downstream (history,
// projection, vis) trusts the contract instead of re-validating.
const malformed = [null, 42, { text: 'x' }, ''];
const tools = malformed.map(
(note, i) =>
new ContentBlocksTool({ output: `payload-${String(i)}`, note } as never),
);
for (const [i, tool] of tools.entries()) {
Object.defineProperty(tool, 'name', { value: `blocks${String(i)}` });
}
const { context } = await runTurn({
tools,
responses: [
makeToolUseResponse(
tools.map((tool, i) => makeToolCall(tool.name, {}, `tc-bad-${String(i)}`)),
),
makeEndTurnResponse('done'),
],
});
const results = context.toolResults();
expect(results).toHaveLength(malformed.length);
for (const [i, entry] of results.entries()) {
expect(entry.result.output).toBe(`payload-${String(i)}`);
expect(entry.result.isError).toBeUndefined();
expect('note' in entry.result).toBe(false);
}
});
it('skips side-effecting tools when usage recording stops the turn', async () => {
const echo = new EchoTool();
const { result, sink, llm } = await runTurn({
tools: [echo],
responses: [makeToolUseResponse([makeToolCall('echo', { text: 'skip' }, 'tc-usage')])],
recordStepUsage: () => ({ stopTurn: true }),
});
expect(result.stopReason).toBe('end_turn');
expect(llm.callCount).toBe(1);
expect(echo.calls).toHaveLength(0);
expect(sink.byType('tool.call')).toHaveLength(0);
expect(sink.byType('tool.result')).toHaveLength(0);
});
it('skips later tool calls after a successful stop-turn result', async () => {
const stop = new StopSuccessTool();
const echo = new EchoTool();
const { result, sink, context } = await runTurn({
tools: [stop, echo],
responses: [
makeToolUseResponse([
makeToolCall('stop-success', {}, 'tc-stop'),
makeToolCall('echo', { text: 'must not run' }, 'tc-echo'),
]),
],
});
expect(result.stopReason).toBe('end_turn');
expect(stop.calls).toHaveLength(1);
expect(echo.calls).toHaveLength(0);
expect(sink.byType('tool.call').map((e) => e.toolCallId)).toEqual(['tc-stop', 'tc-echo']);
expect(sink.byType('tool.result').map((e) => e.toolCallId)).toEqual(['tc-stop', 'tc-echo']);
expect(context.toolResults()[0]?.result).toEqual({ output: 'stopped' });
expect(context.toolResults()[1]?.result).toMatchObject({ isError: true });
expect(context.toolResults()[1]?.result.output).toContain('skipped');
});
it('passes toolCallId / turnId / args through to Tool.execute', async () => {
const echo = new EchoTool();
await runTurn({
tools: [echo],
turnId: 'turn-XYZ',
responses: [
makeToolUseResponse([makeToolCall('echo', { text: 'inputs' }, 'tc-99')]),
makeEndTurnResponse('ok'),
],
});
expect(echo.calls[0]).toMatchObject({
id: 'tc-99',
turnId: 'turn-XYZ',
args: { text: 'inputs' },
});
});
it('records an error tool.result when the tool name is unknown', async () => {
const { sink, context } = await runTurn({
tools: [], // no tools at all
responses: [
makeToolUseResponse([makeToolCall('ghost', { x: 1 }, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(sink.byType('tool.call').length).toBe(1);
const results = sink.byType('tool.result');
expect(results.length).toBe(1);
expect(results[0]?.result.isError).toBe(true);
expect(expectTextOutput(results[0]?.result.output).toLowerCase()).toContain('not found');
// Rejected path also writes the tool.call and tool.result records
const tcRow = context.toolCalls();
const trRow = context.toolResults();
expect(tcRow.length).toBe(1);
expect(tcRow[0]?.args).toEqual({ x: 1 });
expect(trRow.length).toBe(1);
expect(trRow[0]?.result.isError).toBe(true);
});
it('records an error tool.result when args fail tool parameter validation', async () => {
const strict = new StrictArgsTool();
const { sink } = await runTurn({
tools: [strict],
responses: [
makeToolUseResponse([makeToolCall('strict', { value: 'NOT_A_NUMBER' }, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(strict.calls.length).toBe(0); // execute was NOT called
const results = sink.byType('tool.result');
expect(results.length).toBe(1);
expect(results[0]?.result.isError).toBe(true);
expect(expectTextOutput(results[0]?.result.output).toLowerCase()).toContain('invalid args');
});
it('falls back to schema validation when LLM-side args parsing fails', async () => {
const echo = new EchoTool();
const { sink } = await runTurn({
tools: [echo],
responses: [
makeToolUseResponse([
{
type: 'function',
id: 'tc-1',
name: 'echo',
arguments: '{}{',
},
]),
makeEndTurnResponse('done'),
],
});
expect(echo.calls.length).toBe(0);
const results = sink.byType('tool.result');
expect(results.length).toBe(1);
expect(results[0]?.result.isError).toBe(true);
const output = expectTextOutput(results[0]?.result.output);
expect(output).toContain('Invalid args');
expect(output).toContain("must have required property 'text'");
expect(output).not.toContain('malformed JSON in arguments');
expect(output).not.toContain('Expected arguments schema:');
});
it('does not repair malformed tool args JSON', async () => {
const echo = new EchoTool();
const { sink } = await runTurn({
tools: [echo],
responses: [
makeToolUseResponse([
{
type: 'function',
id: 'tc-1',
name: 'echo',
arguments: '{"text":"hi",}',
},
]),
makeEndTurnResponse('done'),
],
});
expect(echo.calls).toHaveLength(0);
const results = sink.byType('tool.result');
expect(results.length).toBe(1);
expect(results[0]?.result.isError).toBe(true);
const output = expectTextOutput(results[0]?.result.output);
expect(output).toContain('Invalid args');
expect(output).toContain("must have required property 'text'");
});
it('captures tool execution failures as error results', async () => {
const fail = new FailingTool('boom');
const { sink, context } = await runTurn({
tools: [fail],
responses: [
makeToolUseResponse([makeToolCall('fail', {}, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(fail.calls.length).toBe(1);
const results = sink.byType('tool.result');
expect(results[0]?.result.isError).toBe(true);
expect(expectTextOutput(results[0]?.result.output).toLowerCase()).toContain('boom');
expect(context.toolResults()[0]?.result.isError).toBe(true);
});
it('logs thrown tool execution failures for diagnostics', async () => {
const fail = new FailingTool('boom');
const { log, entries } = makeTestLogger();
await runTurn({
tools: [fail],
log,
responses: [
makeToolUseResponse([makeToolCall('fail', {}, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(entries).toContainEqual(
expect.objectContaining({
level: 'warn',
message: 'tool execution failed',
payload: expect.objectContaining({
toolName: 'fail',
toolCallId: 'tc-1',
error: expect.any(Error),
}),
}),
);
});
it('coerces an undefined tool return into an error tool.result without breaking pairing', async () => {
const undef: ExecutableTool = {
name: 'undef',
description: 'returns undefined',
parameters: { type: 'object', additionalProperties: true },
resolveExecution: () => ({
approvalRule: 'undef',
execute: async () => undefined as unknown as ExecutableToolResult,
}),
};
const { sink, context } = await runTurn({
tools: [undef],
responses: [
makeToolUseResponse([makeToolCall('undef', {}, 'tc-U')]),
makeEndTurnResponse('done'),
],
});
const callIds = sink.byType('tool.call').map((e) => e.toolCallId);
const resultIds = sink.byType('tool.result').map((e) => e.toolCallId);
expect(resultIds).toEqual(callIds);
const result = context.toolResults()[0]?.result;
expect(result?.isError).toBe(true);
expect(expectTextOutput(result?.output)).toContain('Tool "undef" returned no result');
});
it('coerces a tool returning an object without "output" into an error tool.result', async () => {
const noout: ExecutableTool = {
name: 'noout',
description: 'returns {}',
parameters: { type: 'object', additionalProperties: true },
resolveExecution: () => ({
approvalRule: 'noout',
execute: async () => ({}) as ExecutableToolResult,
}),
};
const { sink, context } = await runTurn({
tools: [noout],
responses: [
makeToolUseResponse([makeToolCall('noout', {}, 'tc-N')]),
makeEndTurnResponse('done'),
],
});
expect(sink.byType('tool.result').length).toBe(1);
const result = context.toolResults()[0]?.result;
expect(result?.isError).toBe(true);
expect(expectTextOutput(result?.output)).toContain('missing or malformed "output" field');
});
it('keeps every tool.call paired when one parallel tool returns a corrupt result', async () => {
const undef: ExecutableTool = {
name: 'undef',
description: 'returns undefined',
parameters: { type: 'object', additionalProperties: true },
resolveExecution: () => ({
approvalRule: 'undef',
execute: async () => undefined as unknown as ExecutableToolResult,
}),
};
const echo = new EchoTool();
const { sink, context } = await runTurn({
tools: [undef, echo],
responses: [
makeToolUseResponse([
makeToolCall('undef', {}, 'tc-U'),
makeToolCall('echo', { text: 'a' }, 'tc-E1'),
makeToolCall('echo', { text: 'b' }, 'tc-E2'),
]),
makeEndTurnResponse('done'),
],
});
const callIds = sink
.byType('tool.call')
.map((e) => e.toolCallId)
.toSorted();
const resultIds = sink
.byType('tool.result')
.map((e) => e.toolCallId)
.toSorted();
expect(callIds).toEqual(['tc-E1', 'tc-E2', 'tc-U']);
expect(resultIds).toEqual(callIds);
expect(context.toolResults().find((r) => r.toolCallId === 'tc-U')?.result.isError).toBe(true);
expect(context.toolResults().find((r) => r.toolCallId === 'tc-E1')?.result.isError).not.toBe(
true,
);
});
it('coerces a corrupt finalizeToolResult hook return into an error result', async () => {
const echo = new EchoTool();
const hooks: LoopHooks = {
finalizeToolResult: async () => ({}) as ExecutableToolResult,
};
const { sink, context } = await runTurn({
tools: [echo],
hooks,
responses: [
makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-E')]),
makeEndTurnResponse('done'),
],
});
expect(sink.byType('tool.result').length).toBe(1);
const result = context.toolResults()[0]?.result;
expect(result?.isError).toBe(true);
expect(expectTextOutput(result?.output)).toContain('missing or malformed "output" field');
});
it('does not duplicate prepare hook failures in the diagnostic log', async () => {
const echo = new EchoTool();
const { log, entries } = makeTestLogger();
await runTurn({
tools: [echo],
log,
hooks: {
prepareToolExecution: async () => {
throw new Error('hook exploded');
},
},
responses: [
makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(entries).not.toContainEqual(
expect.objectContaining({ message: 'prepareToolExecution hook failed' }),
);
});
it('does not log path security setup failures already covered by tool.result', async () => {
const tool = new PathSecurityTool();
const { log, entries } = makeTestLogger();
const { sink } = await runTurn({
tools: [tool],
log,
responses: [
makeToolUseResponse([makeToolCall('path-secure', {}, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
expect(sink.byType('tool.result')[0]?.result.isError).toBe(true);
expect(entries).not.toContainEqual(
expect.objectContaining({ message: 'tool execution setup failed' }),
);
});
it('forwards onUpdate calls as tool.progress events', async () => {
const progress = new ProgressTool([
{ kind: 'stdout', text: 'a' },
{ kind: 'progress', percent: 25 },
{ kind: 'progress', percent: 75 },
]);
const { sink } = await runTurn({
tools: [progress],
responses: [
makeToolUseResponse([makeToolCall('progress', {}, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
const progressEvents = sink.byType('tool.progress');
expect(progressEvents.length).toBe(3);
expect(progressEvents.every((e) => e.toolCallId === 'tc-1')).toBe(true);
expect(progressEvents.map((e) => e.update.kind)).toEqual(['stdout', 'progress', 'progress']);
});
it('runs multiple tool calls in the same step (transcripts in provider order)', async () => {
const echo = new EchoTool();
const { sink, context } = await runTurn({
tools: [echo],
responses: [
makeToolUseResponse([
makeToolCall('echo', { text: 'a' }, 'tc-1'),
makeToolCall('echo', { text: 'b' }, 'tc-2'),
makeToolCall('echo', { text: 'c' }, 'tc-3'),
]),
makeEndTurnResponse('done'),
],
});
expect(echo.calls.map((c) => c.id)).toEqual(['tc-1', 'tc-2', 'tc-3']);
// tool.result is dispatched in provider order regardless of
// execute completion order
expect(context.toolResults().map((r) => r.toolCallId)).toEqual(['tc-1', 'tc-2', 'tc-3']);
expect(sink.byType('tool.call').map((e) => e.toolCallId)).toEqual(['tc-1', 'tc-2', 'tc-3']);
});
it('executes non-conflicting tool tasks concurrently (causal order, not wall-clock)', async () => {
// Two GatedTools start, each blocks until released. If the loop executed
// them serially we could not have both `started` promises resolve
// before releasing either.
const a = markReadFileAccesses(new GatedTool('gated-a'));
const b = markReadFileAccesses(new GatedTool('gated-b'));
const turnPromise = runTurn({
tools: [a, b],
responses: [
makeToolUseResponse([
makeToolCall('gated-a', {}, 'tc-a'),
makeToolCall('gated-b', {}, 'tc-b'),
]),
makeEndTurnResponse('done'),
],
});
// Both tools must enter plan execution before either releases.
await Promise.all([a.started, b.started]);
// Release in reverse provider order.
b.release();
a.release();
const { context, sink } = await turnPromise;
// Despite execute completing in reverse order, tool.result records
// are written in provider order.
expect(context.toolResults().map((r) => r.toolCallId)).toEqual(['tc-a', 'tc-b']);
// Each tool emitted its tool.call and tool.result.
expect(
sink
.byType('tool.call')
.map((e) => e.toolCallId)
.toSorted(),
).toEqual(['tc-a', 'tc-b'].toSorted());
expect(
sink
.byType('tool.result')
.map((e) => e.toolCallId)
.toSorted(),
).toEqual(['tc-a', 'tc-b'].toSorted());
});
it('serializes tool tasks with default accesses', async () => {
const a = new GatedTool('gated-a');
const b = new GatedTool('gated-b');
const turnPromise = runTurn({
tools: [a, b],
responses: [
makeToolUseResponse([
makeToolCall('gated-a', {}, 'tc-a'),
makeToolCall('gated-b', {}, 'tc-b'),
]),
makeEndTurnResponse('done'),
],
});
await a.started;
await expect(
Promise.race([b.started.then(() => true), waitOneMacrotask().then(() => false)]),
).resolves.toBe(false);
a.release();
await b.started;
b.release();
const { context } = await turnPromise;
expect(context.toolResults().map((r) => r.toolCallId)).toEqual(['tc-a', 'tc-b']);
});
it('computes resource accesses after prepareToolExecution updates args', async () => {
const tool = new PathLockedGatedTool();
const hooks: LoopHooks = {
prepareToolExecution: async ({ toolCall }) =>
toolCall.id === 'tc-b' ? { updatedArgs: { path: '/repo/a.ts' } } : undefined,
};
const turnPromise = runTurn({
tools: [tool],
hooks,
responses: [
makeToolUseResponse([
makeToolCall('path-gated', { path: '/repo/a.ts' }, 'tc-a'),
makeToolCall('path-gated', { path: '/repo/b.ts' }, 'tc-b'),
]),
makeEndTurnResponse('done'),
],
});
await tool.waitForStartedCount(1);
await waitOneMacrotask();
expect(tool.startedIds).toEqual(['tc-a']);
tool.release('tc-a');
await tool.waitForStartedCount(2);
expect(tool.startedIds).toEqual(['tc-a', 'tc-b']);
tool.release('tc-b');
const { context } = await turnPromise;
expect(context.toolResults().map((r) => r.toolCallId)).toEqual(['tc-a', 'tc-b']);
expect(tool.calls.map((call) => call.args.path)).toEqual(['/repo/a.ts', '/repo/a.ts']);
});
it('starts later independent tool tasks behind an earlier queued conflict', async () => {
const tool = new PathLockedGatedTool();
const turnPromise = runTurn({
tools: [tool],
responses: [
makeToolUseResponse([
makeToolCall('path-gated', { path: '/repo/a.ts' }, 'tc-a-1'),
makeToolCall('path-gated', { path: '/repo/a.ts' }, 'tc-a-2'),
makeToolCall('path-gated', { path: '/repo/b.ts' }, 'tc-b'),
]),
makeEndTurnResponse('done'),
],
});
await tool.waitForStartedCount(2);
await waitOneMacrotask();
expect(tool.startedIds).toEqual(['tc-a-1', 'tc-b']);
tool.release('tc-b');
await waitOneMacrotask();
expect(tool.startedIds).toEqual(['tc-a-1', 'tc-b']);
tool.release('tc-a-1');
await tool.waitForStartedCount(3);
expect(tool.startedIds).toEqual(['tc-a-1', 'tc-b', 'tc-a-2']);
tool.release('tc-a-2');
const { context } = await turnPromise;
expect(context.toolResults().map((r) => r.toolCallId)).toEqual(['tc-a-1', 'tc-a-2', 'tc-b']);
});
it('preserves structured ExecutableToolResult output on transcript and live result event', async () => {
const blocks = new ContentBlocksTool({
output: [
{ type: 'text', text: 'see image:' },
{
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,AAAA' },
},
],
});
const { context, sink } = await runTurn({
tools: [blocks],
responses: [
makeToolUseResponse([makeToolCall('blocks', {}, 'tc-1')]),
makeEndTurnResponse('done'),
],
});
const payload = context.toolResults()[0]?.result;
expect(payload).toBeDefined();
// Media-bearing results are kept as a content-part array, not collapsed.
expect(Array.isArray(payload?.output)).toBe(true);
if (Array.isArray(payload?.output)) {
const types = payload.output.map((p) => (p as { type: string }).type);
expect(types).toContain('text');
expect(types).toContain('image_url');
}
const eventOutput = sink.byType('tool.result')[0]?.result.output;
expect(Array.isArray(eventOutput)).toBe(true);
});
it('preserves media-only image output with a text companion', async () => {
const image: ContentPart = {
type: 'image_url',
imageUrl: { url: 'https://example.com/image.jpg' },
};
const output = await contentBlockOutput([image]);
expect(output[0]).toMatchObject({
type: 'text',
text: 'Tool returned non-text content.',
});
expect(output.some((part) => part.type === 'image_url')).toBe(true);
});
it('preserves a list of only image ContentParts', async () => {
const img1: ContentPart = {
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,abc' },
};
const img2: ContentPart = {
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,def' },
};
const output = await contentBlockOutput([img1, img2]);
expect(output.filter((part) => part.type === 'image_url').length).toBe(2);
});
it('preserves an audio-only output', async () => {
const audio: ContentPart = {
type: 'audio_url',
audioUrl: { url: 'data:audio/mp3;base64,abc' },
};
const output = await contentBlockOutput([audio]);
expect(output.some((part) => part.type === 'audio_url')).toBe(true);
});
it('preserves an image when the tool emits a single image and no text channel exists', async () => {
const image: ContentPart = {
type: 'image_url',
imageUrl: { url: 'https://example.com/img.jpg' },
};
const output = await contentBlockOutput([image]);
expect(output.find((part) => part.type === 'image_url')).toBeDefined();
});
it('every tool.call event has a matching tool.result event (mixed batch)', async () => {
const echo = new EchoTool();
const fail = new FailingTool();
const strict = new StrictArgsTool();
const { sink } = await runTurn({
tools: [echo, fail, strict],
responses: [
makeResponse(
makeTextParts(''),
[
makeToolCall('echo', { text: 'ok' }, 'tc-good'),
makeToolCall('fail', {}, 'tc-fail'),
makeToolCall('strict', { value: 'oops' }, 'tc-bad-args'),
makeToolCall('ghost', {}, 'tc-missing'),
],
'tool_use',
),
makeEndTurnResponse('done'),
],
});
const callIds = sink
.byType('tool.call')
.map((e) => e.toolCallId)
.toSorted();
const resultIds = sink
.byType('tool.result')
.map((e) => e.toolCallId)
.toSorted();
expect(callIds).toEqual(['tc-bad-args', 'tc-fail', 'tc-good', 'tc-missing']);
expect(resultIds).toEqual(callIds);
});
});
interface PathLockedInput {
readonly path: string;
}
class PathLockedGatedTool implements ExecutableTool<PathLockedInput> {
readonly name = 'path-gated';
readonly description = 'Waits behind a path-scoped write lock.';
readonly parameters: Record<string, unknown> = {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
};
readonly calls: Array<{ readonly id: string; readonly args: PathLockedInput }> = [];
readonly startedIds: string[] = [];
private readonly gateResolvers = new Map<string, () => void>();
private readonly startedWaiters: Array<{ readonly count: number; readonly resolve: () => void }> =
[];
resolveExecution(input: PathLockedInput): ToolExecution {
return {
accesses: ToolAccesses.writeFile(input.path),
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
args: input,
});
this.startedIds.push(ctx.toolCallId);
this.resolveStartedWaiters();
await new Promise<void>((resolve) => {
this.gateResolvers.set(ctx.toolCallId, resolve);
});
return { output: `${input.path} done` };
},
};
}
release(toolCallId: string): void {
this.gateResolvers.get(toolCallId)?.();
}
waitForStartedCount(count: number): Promise<void> {
if (this.startedIds.length >= count) return Promise.resolve();
return new Promise((resolve) => {
this.startedWaiters.push({ count, resolve });
});
}
private resolveStartedWaiters(): void {
for (let i = this.startedWaiters.length - 1; i >= 0; i--) {
const waiter = this.startedWaiters[i];
if (waiter !== undefined && this.startedIds.length >= waiter.count) {
this.startedWaiters.splice(i, 1);
waiter.resolve();
}
}
}
}
class PathSecurityTool implements ExecutableTool<Record<string, unknown>> {
readonly name = 'path-secure';
readonly description = 'Fails path checks during setup.';
readonly parameters: Record<string, unknown> = {
type: 'object',
additionalProperties: true,
};
resolveExecution(): ToolExecution {
throw new PathSecurityError(
'PATH_OUTSIDE_WORKSPACE',
'../secret',
'/secret',
'Path is outside workspace.',
);
}
}
class StopSuccessTool implements ExecutableTool<Record<string, unknown>> {
readonly name = 'stop-success';
readonly description = 'Returns a successful result that stops the turn.';
readonly parameters: Record<string, unknown> = {
type: 'object',
additionalProperties: true,
};
readonly calls: Array<{ readonly id: string }> = [];
resolveExecution(): ToolExecution {
return {
stopBatchAfterThis: true,
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({ id: ctx.toolCallId });
return { output: 'stopped', stopTurn: true };
},
};
}
}
describe('runTurn — unexecuted tool calls (abnormal step end)', () => {
it('records and closes a complete tool call when the step ends paused', async () => {
const echo = new EchoTool();
const { result, context, sink } = await runTurn({
tools: [echo],
responses: [
makeResponse(makeThinkingParts('let me run a tool'), [makeToolCall('echo', { text: 'hi' }, 'tc-1')], 'paused'),
],
});
// The call must not execute, but it is recorded and immediately closed
// with a synthetic interrupted result so the exchange stays wire-valid.
expect(echo.calls.length).toBe(0);
expect(result.stopReason).toBe('paused');
expect(context.toolCalls().map((e) => [e.toolCallId, e.name, e.args])).toEqual([
['tc-1', 'echo', { text: 'hi' }],
]);
const results = context.toolResults();
expect(results.map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(results[0]?.result.isError).toBe(true);
expect(expectTextOutput(results[0]?.result.output)).toContain('not executed');
// Events pair up in the live stream too, and the step seals as paused.
expect(sink.byType('tool.call').map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(sink.byType('tool.result').map((e) => e.toolCallId)).toEqual(['tc-1']);
expect(context.stepEnds()[0]?.finishReason).toBe('paused');
});
it('sanitizes truncated (unparseable) arguments to {} when recording the call', async () => {
// The provider stream was cut mid-arguments: id and name arrived, the JSON
// did not. The recorded call must carry sanitized args — the raw fragment
// would crash the next request's wire conversion.
const partialCall: ToolCall = {
type: 'function',
id: 'tc-partial',
name: 'echo',
arguments: '{"text":"unterminated',
};
const echo = new EchoTool();
const { context } = await runTurn({
tools: [echo],
responses: [
// The realistic poison shape: an empty signed thinking block plus the
// partial call (what a pause_turn response carries).
makeResponse([{ type: 'think', think: '', encrypted: 'sig' }], [partialCall], 'paused'),
],
});
expect(echo.calls.length).toBe(0);
expect(context.toolCalls().map((e) => [e.toolCallId, e.args])).toEqual([['tc-partial', {}]]);
const results = context.toolResults();
expect(results.map((e) => e.toolCallId)).toEqual(['tc-partial']);
expect(results[0]?.result.isError).toBe(true);
});
it('records and closes tool calls when the stream fails overloaded (other finish)', async () => {
const echo = new EchoTool();
const { result, context } = await runTurn({
tools: [echo],
responses: [
makeResponse(makeTextParts(''), [makeToolCall('echo', { text: 'hi' }, 'tc-ovl')], 'unknown'),
],
});
expect(echo.calls.length).toBe(0);
expect(result.stopReason).toBe('unknown');
expect(context.toolCalls().map((e) => e.toolCallId)).toEqual(['tc-ovl']);
expect(context.toolResults().map((e) => e.toolCallId)).toEqual(['tc-ovl']);
expect(context.toolResults()[0]?.result.isError).toBe(true);
});
it('records and closes a tool call cut off by max_tokens', async () => {
const echo = new EchoTool();
const { result, context } = await runTurn({
tools: [echo],
responses: [
makeResponse(makeTextParts('partial answer'), [makeToolCall('echo', { text: 'hi' }, 'tc-max')], 'max_tokens'),
],
});
expect(echo.calls.length).toBe(0);
expect(result.stopReason).toBe('max_tokens');
expect(context.toolCalls().map((e) => e.toolCallId)).toEqual(['tc-max']);
expect(context.toolResults().map((e) => e.toolCallId)).toEqual(['tc-max']);
expect(context.toolResults()[0]?.result.isError).toBe(true);
});
it('closes every unexecuted call in provider order', async () => {
const echo = new EchoTool();
const { context } = await runTurn({
tools: [echo],
responses: [
makeResponse(
makeThinkingParts('two calls'),
[makeToolCall('echo', { text: 'a' }, 'tc-a'), makeToolCall('echo', { text: 'b' }, 'tc-b')],
'paused',
),
],
});
expect(echo.calls.length).toBe(0);
expect(context.toolCalls().map((e) => e.toolCallId)).toEqual(['tc-a', 'tc-b']);
expect(context.toolResults().map((e) => e.toolCallId)).toEqual(['tc-a', 'tc-b']);
});
it('does not record synthetic results for a normal end_turn without tool calls', async () => {
const { context } = await runTurn({
responses: [makeEndTurnResponse('done')],
});
expect(context.toolCalls()).toEqual([]);
expect(context.toolResults()).toEqual([]);
});
});