kimi-code/packages/agent-core/test/agent/turn.test.ts
liruifengv b51e13538d
ci: run unit tests on windows (#1037)
* ci: run unit tests on windows

* fix(migration-legacy): align workdir bucket key with agent-core

computeWorkdirBucket used a local node:path-based resolve that yields backslash-separated paths on Windows, while agent-core's encodeWorkDirKey uses pathe (forward slashes on every platform). The SHA-256 inputs diverged, so migrated sessions were written to a bucket that the session picker never reads, making them invisible on Windows.

Alias computeWorkdirBucket to encodeWorkDirKey so both sides stay byte-identical, drop the local slugify copy, and update the workdir-bucket test reference accordingly.

* test(acp-adapter): expect platform-native separators in e2e-fs path

The e2e-fs test asserted the fs/readTextFile wire path as the raw POSIX targetPath, but AcpKaos.toClientPath converts '/' to '\' when the inner LocalKaos reports pathClass 'win32' (Windows). On Windows the wire path became '\Users\test\x.ts' and the assertion failed.

Mirror toClientPath in the test: expect backslash separators on win32 and the raw path otherwise. Implementation is unchanged.

* test(sdk): normalize workDir and skillDir paths in session tests

SessionStore.create/list and the skill loader normalize paths through pathe (forward slashes). The SDK tests compared the resulting workDir and skill loaded-dir against raw mkdtemp / node:path strings, which use backslashes on Windows (and node:fs realpath also returns backslashes for the skill dir), failing three toMatchObject assertions.

Build the expected paths with agent-core's normalizeWorkDir so they match the internal pathe representation on every platform. The skill dir keeps its realpath() (the loader realpaths the root) and only normalizes separators.

* test(skill): normalize realpath to forward slashes in scanner tests

resolveSkillRoots normalizes every root.path through fs.realpath followed by replacing backslashes with forward slashes (scanner.ts). The scanner tests compared root.path against node:fs realpath directly, which returns backslashes on Windows, so twenty assertions failed (toEqual / toContain / toHaveLength) even though the resolved paths were identical.

Wrap realpath at the top of the test file to mirror the implementation's normalization, so every comparison uses the same forward-slash form on every platform.

* test: skip Unix-only permission tests on Windows

The Unix file-permission assertions (mode bits like 0o600 / 0o700 and chmod 000 making a path unreadable) have no equivalent on Windows, which uses ACLs; fs.chmod there can only toggle the read-only bit. These six tests failed on Windows with mismatched mode values or a missing 40411.

Skip them on win32 via it.skipIf(process.platform === 'win32'): oauth FileTokenStorage (0600 file, 0700 dir), agent-core BackgroundTaskPersistence (0700 tasks dir), agent-core createPerIdJsonStore (0700 subdir), migration-legacy atomicWrite (0600 file), and server fs:browse (chmod 000 -> 40411).

* test(tui): make platform-sensitive assertions cross-platform

The TUI implementations are already platform-aware (pathe-style paths, pathToFileURL, quoteShellArg cmd/POSIX quoting, Alt+V on Windows for paste expansion), but the tests hard-coded POSIX expectations and failed on Windows.

Align the assertions with the implementation's platform behavior: footer-goal-badge matches the '[goal' badge prefix instead of /goal/ (toolbar tips contain '/goal'); tool-call expects backslash relative paths on win32; plan-box builds the file:// URL via pathToFileURL; custom-editor sends Alt+V on win32 for paste expansion; file-mention-provider normalizes the expected description to forward slashes; kimi-tui-startup builds the resume command with quoteShellArg; kimi-tui-message-flow builds the expected install path with resolve().

* test: align path assertions with pathe on Windows

Several test suites asserted paths produced by node:path/node:os/node:fs against values that agent-core, node-sdk and kaos normalize through pathe (forward slashes). On Windows the two forms diverge (backslashes vs forward slashes), failing about 19 assertions.

Mirror the implementation's normalization in the assertions via a local toPosix helper (or agent-core's normalizeWorkDir), so expected paths use forward slashes on every platform: kaos LocalKaos, node-sdk export/list/resume/config/transport sessions, cli FileMentionProvider, and agent-core skill-session.

* test(native): build path expectations with node:path.resolve

paths.mjs builds every path with node:path.resolve, which yields backslash-separated absolute paths on Windows. The path-helpers tests asserted against template strings that mixed the backslash appRoot with forward-slash segments, so Object.is failed on Windows even though the strings looked identical.

Build the expectations with the same resolve(appRoot, ...) helper so the separators match on every platform.

* fix: make Windows CI tests pass across all packages

Fix the remaining Windows CI failures so the Windows test job can go green. The changes fall into a few categories:

- Path separators: agent-core/node-sdk/kaos normalize paths via pathe (forward slashes); align test expectations and a couple of implementations (native cache base, workspace registry) with that.

- Platform-only services: skip launchd/systemd manager suites on win32 (Windows uses schtasks).

- Process/signal lifecycle: skip or relax tests that rely on POSIX signals / SIGTERM semantics that Windows does not support.

- Hook shell syntax: rewrite hook test commands from POSIX shell (single quotes, semicolons, stderr redirects, if/then/fi) to node -e / .cjs files that run under cmd.exe.

- CRLF: make Bash tool description stripping tolerate CRLF line endings.

- Misc: realpath short-name divergence, port-retry timing, telemetry spawn, fs-watch timing, snapshot path normalization, etc.

* fix: remove unused basename import in workspaceRegistryService

Fix lint error (no-unused-vars): basename from node:path is no longer used after switching to posixBasename from pathe.

* fix: align resume harness pathClass and wait for banner state on Windows

Two more Windows CI fixes:

- createResumeNoSideEffectKaos now reports pathClass 'win32' on Windows so tool descriptions (e.g. Glob's Windows note) match the live agent in expectResumeMatches, fixing usage/description deep-equal drift.

- kimi-tui-startup once-banner test now waits for writeBannerDisplayState to land before asserting, since the atomic write can lag behind the render on Windows.

* fix: resolve remaining Windows unit test failures

Make the new Windows CI job green across agent-core, kaos, node-sdk and server:

- Align the resume harness kaos pathClass with the live agent so platform-conditional tool descriptions (Glob's Windows note) match in expectResumeMatches instead of drifting on win32.
- Rewrite hook commands in agent-core tests as cross-platform node one-liners; single-quote echo, >&2 and ';' do not work under cmd.exe.
- Add .gitattributes enforcing LF so raw-imported templates (e.g. the compaction instruction) produce byte-identical token counts on Windows and POSIX.
- Terminate the full process tree on Windows in both the hook runner and kaos (taskkill /T /F) so grandchildren cannot outlive their parent and keep the cwd locked.
- Normalize workDir path separators in two kimi-sdk session tests to match the stored canonical form.
- Avoid cmd.exe arg-quoting pitfalls in the kaos cmd.exe test, and run the Windows process-tree kill test from a script file with the pid path passed via argv.
- Give the first fs-git e2e test more time on Windows and retry the temp-dir cleanup; skip the fs-watch overflow-burst assertion on Windows where fs-event coalescing prevents the single-window spike.

* ci: retrigger checks

* fix: resolve remaining Windows failures after merging main

- Terminate the spawned git/gh process tree on Windows in FsGitService (taskkill /T /F on timeout) so a timed-out 'gh pr view' cannot leave a grandchild holding the workspace cwd, which made the fs-git e2e cleanup fail with EPERM.

- Give the fs:git_status e2e suite a longer timeout on Windows and retry the temp-dir cleanup longer to ride out the slower child-process teardown.

- Make the third-party plugin install trust test assert the resolved install path via node:path so it matches the Windows-resolved path (D:\tmp\...) as well as the POSIX one.

* fix: align workspace registry roots and harden fs-git cleanup on Windows

- workspace-registry test: compare normalized (forward-slash) roots, since the registry and session index both store workDir via pathe.resolve (forward slashes on every platform). realpath() yields backslashes on Windows and diverged from the stored root.

- fs-git e2e: bump the temp-dir cleanup retries and the afterEach timeout, since Windows child-process teardown after server.close() is asynchronous and can keep the workspace cwd locked for several seconds.

* test: stub openUrl in kimi-tui-message-flow feedback tests

The /feedback command falls back to openUrl(FEEDBACK_ISSUE_URL) when submission fails, which spawned a real browser window on every test run. Mock #/utils/open-url (matching the existing login/message-replay/server test convention) so the suite never opens a browser.

* test: harden fs-git e2e cleanup against Windows cwd locks

On Windows, git/gh child processes and the session core process can outlive server.close() and keep the temp workspace as their cwd, so rmSync fails with EPERM even after a long retry. Add rmSyncRobust that retries and, if the cwd is still locked, swallows EPERM/EBUSY on Windows — the OS reclaims the temp dir and a cleanup hiccup must not fail an otherwise-passing test.

* test: harden server e2e cleanup against async teardown races

server.close() does not fully await the server's asynchronous teardown, so on a loaded CI runner the temp home/workspace dirs can still be held or written to when the afterEach rmSync runs, failing with EPERM (Windows) or ENOTEMPTY (Linux). Use a rmSyncRobust helper (retry + swallow EPERM/EBUSY/ENOTEMPTY) in the fs-git and question e2e cleanup. Also fix a leftover `throw err` (renamed to `throw error`) that broke the typecheck.
2026-06-26 11:56:41 +08:00

1899 lines
74 KiB
TypeScript

import { existsSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { setTimeout as delay } from 'node:timers/promises';
import type { Kaos } from '@moonshot-ai/kaos';
import {
APIConnectionError,
APIEmptyResponseError,
APIStatusError,
APITimeoutError,
type ChatProvider,
type ModelCapability,
type ToolCall,
} from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { HookEngine } from '../../src/session/hooks';
import { abortError } from '../../src/utils/abort';
import type { AgentOptions } from '../../src/agent';
import { ErrorCodes, KimiError } from '../../src/errors';
import type { Logger, LogPayload } from '../../src/logging';
import type {
QueuedSubagentRunResult,
QueuedSubagentTask,
SessionSubagentHost,
} from '../../src/session/subagent-host';
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createCommandKaos, testAgent, type TestAgentOptions } from './harness/agent';
import { executeTool } from '../tools/fixtures/execute-tool';
type GenerateFn = NonNullable<AgentOptions['generate']>;
interface CapturedLogEntry {
readonly level: 'error' | 'warn' | 'info' | 'debug';
readonly message: string;
readonly payload: LogPayload | undefined;
}
function captureLogs(): { logger: Logger; entries: CapturedLogEntry[] } {
const entries: CapturedLogEntry[] = [];
const capture =
(level: CapturedLogEntry['level']) => (message: string, payload?: LogPayload) => {
entries.push({ level, message, payload });
};
const logger: Logger = {
error: capture('error'),
warn: capture('warn'),
info: capture('info'),
debug: capture('debug'),
createChild: () => logger,
};
return { logger, entries };
}
describe('Agent turn flow', () => {
it('tracks turn_started and turn_interrupted telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello without login' }] });
await ctx.untilTurnEnd();
expect(records).toContainEqual({
event: 'turn_started',
properties: { mode: 'agent' },
});
expect(records).toContainEqual({
event: 'turn_interrupted',
properties: { mode: 'agent', at_step: 0 },
});
});
it('tracks duplicate tool-call detection telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({
kaos: createCommandKaos('dup'),
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
records.length = 0;
ctx.mockNextResponse(
bashCallWithId('call_dup_1', 'printf dup'),
bashCallWithId('call_dup_2', 'printf dup'),
);
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run duplicates' }] });
await ctx.untilTurnEnd();
expect(records).toContainEqual({
event: 'tool_call_dedup_detected',
properties: {
turn_id: 0,
step_no: 1,
tool_name: 'Bash',
dup_type: 'same_step',
args_hash: expect.any(String),
},
});
expect(records).toContainEqual({
event: 'permission_policy_decision',
properties: expect.objectContaining({
policy_name: 'yolo-mode-approve',
tool_name: 'Bash',
permission_mode: 'yolo',
decision: 'approve',
}),
});
});
it('tracks cross-step duplicate tool-call detection telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({
kaos: createCommandKaos('dup'),
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
records.length = 0;
ctx.mockNextResponse(bashCallWithId('call_dup_1', 'printf dup'));
ctx.mockNextResponse(bashCallWithId('call_dup_2', 'printf dup'));
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run duplicates across steps' }] });
await ctx.untilTurnEnd();
expect(records).toContainEqual({
event: 'tool_call_dedup_detected',
properties: {
turn_id: 0,
step_no: 2,
tool_name: 'Bash',
dup_type: 'cross_step',
args_hash: expect.any(String),
},
});
expect(records).toContainEqual({
event: 'tool_call',
properties: expect.objectContaining({
tool_name: 'Bash',
outcome: 'success',
dup_type: 'cross_step',
duration_ms: expect.any(Number),
}),
});
});
it('fires PostToolUse for same-step dups with the original real output, not the dedup placeholder', async () => {
// Hook command asserts the dup's PostToolUse payload carries the real
// stdout ('dup'), not the placeholder ('').
const assertScript = [
"let input = '';",
"process.stdin.on('data', (chunk) => { input += chunk; });",
"process.stdin.on('end', () => {",
' const payload = JSON.parse(input);',
" if (typeof payload.tool_output === 'string' && payload.tool_output.includes('dup')) process.exit(0);",
" console.error('bad tool_output: ' + JSON.stringify(payload.tool_output));",
' process.exit(2);',
'});',
].join('');
const resolved: Array<[string, string, string]> = [];
const hookEngine = new HookEngine(
[
{
event: 'PostToolUse',
matcher: 'Bash',
command: `node -e ${JSON.stringify(assertScript)}`,
},
],
{
onResolved: (event, target, action) => {
resolved.push([event, target, action]);
},
},
);
const ctx = testAgent({ kaos: createCommandKaos('dup'), hookEngine });
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
ctx.mockNextResponse(
bashCallWithId('call_dup_1', 'printf dup'),
bashCallWithId('call_dup_2', 'printf dup'),
);
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run duplicates' }] });
await ctx.untilTurnEnd();
await vi.waitFor(() => {
expect(resolved).toEqual([
['PostToolUse', 'Bash', 'allow'],
['PostToolUse', 'Bash', 'allow'],
]);
});
});
it('tracks failed tool-call telemetry with error taxonomy', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
ctx.configure();
records.length = 0;
ctx.mockNextResponse({
type: 'function',
id: 'call_missing',
name: 'MissingTool',
arguments: '{}',
});
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Call a missing tool' }] });
await ctx.untilTurnEnd();
expect(records).toContainEqual({
event: 'tool_call',
properties: expect.objectContaining({
tool_name: 'MissingTool',
outcome: 'error',
dup_type: 'normal',
error_type: 'ToolNotFound',
duration_ms: expect.any(Number),
}),
});
});
it('emits a failed turn and error when generation fails', async () => {
const ctx = testAgent();
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger generate failure' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] turn.prompt { "input": [ { "type": "text", "text": "Trigger generate failure" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Trigger generate failure" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "error", "message": "Unexpected generate call #1" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "internal", "message": "Unexpected generate call #1", "name": "Error", "retryable": false, "details": { "turnId": 0 } } }
`);
expect(ctx.newEvents()).toMatchInlineSnapshot(
`[emit] error { "code": "internal", "message": "Unexpected generate call #1", "name": "Error", "retryable": false, "details": { "turnId": 0 } }`,
);
await ctx.expectResumeMatches();
});
it('keeps manual swarm mode active after a turn completes normally', async () => {
const ctx = testAgent();
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'swarm done' });
await ctx.rpc.enterSwarm({ trigger: 'manual' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a swarm task' }] });
await ctx.untilTurnEnd();
expect(ctx.agent.swarmMode.isActive).toBe(true);
expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBe(-1);
await ctx.expectResumeMatches();
});
it('exits task swarm mode after a turn completes normally', async () => {
const ctx = testAgent();
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'swarm done' });
await ctx.rpc.enterSwarm({ trigger: 'task' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a swarm task' }] });
await ctx.untilTurnEnd();
const turnEndedIndex = eventIndex(ctx, '[rpc]', 'turn.ended');
const swarmExitIndex = eventIndex(ctx, '[wire]', 'swarm_mode.exit');
const inactiveStatusIndex = ctx.allEvents.findIndex((entry, index) => {
return (
index > turnEndedIndex &&
entry.type === '[rpc]' &&
entry.event === 'agent.status.updated' &&
(entry.args as { readonly swarmMode?: boolean }).swarmMode === false
);
});
expect(ctx.agent.swarmMode.isActive).toBe(false);
expect(swarmExitIndex).toBeGreaterThan(turnEndedIndex);
expect(inactiveStatusIndex).toBeGreaterThan(turnEndedIndex);
expect(ctx.agent.context.history.at(-1)?.origin).toEqual({
kind: 'injection',
variant: 'swarm_mode_exit',
});
await ctx.expectResumeMatches();
});
it('exits task swarm mode when the swarm turn fails', async () => {
const ctx = testAgent();
ctx.configure();
await ctx.rpc.enterSwarm({ trigger: 'task' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fail a swarm task' }] });
await ctx.untilTurnEnd();
expect(ctx.agent.swarmMode.isActive).toBe(false);
expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBeGreaterThan(-1);
});
it('exits task swarm mode when the user cancels the swarm turn', async () => {
const ctx = testAgent({ generate: abortableGenerate });
ctx.configure();
const stepStarted = ctx.once('turn.step.started');
await ctx.rpc.enterSwarm({ trigger: 'task' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Cancel a swarm task' }] });
await stepStarted;
await ctx.rpc.cancel({ turnId: 0 });
await ctx.untilTurnEnd();
expect(ctx.agent.swarmMode.isActive).toBe(false);
expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBeGreaterThan(-1);
});
it('enters silent swarm mode when the agent calls AgentSwarm', async () => {
const runQueued = vi.fn(async <T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> => {
return tasks.map((task, index) => ({
task,
agentId: `agent-${String(index + 1)}`,
status: 'completed' as const,
result: `result ${String(index + 1)}`,
}));
});
const subagentHost = mockSubagentHost({
runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
});
const ctx = testAgent({
subagentHost,
});
ctx.configure({ tools: ['AgentSwarm'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
ctx.mockNextResponse(
{ type: 'text', text: 'I will launch a swarm.' },
agentSwarmCall(),
);
ctx.mockNextResponse({ type: 'text', text: 'Swarm results reviewed.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Use AgentSwarm' }] });
await ctx.untilTurnEnd();
const enterEvent = ctx.allEvents.find(
(entry) => entry.type === '[wire]' && entry.event === 'swarm_mode.enter',
);
const reminderOrigins = ctx.agent.context.history
.map((message) => message.origin)
.filter((origin) => origin?.kind === 'injection');
expect(runQueued).toHaveBeenCalledTimes(1);
expect(enterEvent?.args).toMatchObject({ trigger: 'tool' });
expect(ctx.agent.swarmMode.isActive).toBe(false);
expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBeGreaterThan(
eventIndex(ctx, '[rpc]', 'turn.ended'),
);
expect(reminderOrigins).not.toContainEqual({ kind: 'injection', variant: 'swarm_mode' });
expect(reminderOrigins).not.toContainEqual({
kind: 'injection',
variant: 'swarm_mode_exit',
});
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('ends the turn with reason filtered when the provider filters a non-empty response', async () => {
const generate: GenerateFn = async () => ({
id: null,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'some filtered text' }],
toolCalls: [],
},
usage: {
inputOther: 10,
output: 5,
inputCacheRead: 0,
inputCacheCreation: 0,
},
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: 'filtered',
}),
}),
);
expect(events).not.toContainEqual(
expect.objectContaining({
type: '[rpc]',
event: 'turn.ended',
args: expect.objectContaining({ reason: 'completed' }),
}),
);
});
it('emits a friendly model.not_configured error when no model is configured', async () => {
const ctx = testAgent();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello without login' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] metadata { "protocol_version": "<protocol-version>", "created_at": "<time>" }
[wire] turn.prompt { "input": [ { "type": "text", "text": "Hello without login" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
`);
expect(ctx.newEvents()).toMatchInlineSnapshot(
`[emit] error { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false }`,
);
});
it('continues the turn after projecting UserPromptSubmit hook output', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
matcher: 'hooked input',
command:
'node -e "let s=\\"\\";process.stdin.on(\\"data\\",d=>s+=d);process.stdin.on(\\"end\\",()=>{const o=JSON.parse(s);if(Array.isArray(o.prompt)&&o.prompt[0]?.text===\\"hooked input\\"){process.stdout.write(\\"hook response 1\\");process.exit(0);}console.error(\\"bad prompt\\");process.exit(1);})"',
},
{
event: 'UserPromptSubmit',
matcher: 'hooked input',
command: 'node -e "process.stdout.write(\'hook response 2\')"',
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'model saw original prompt only' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hooked input' }] });
const events = await ctx.untilTurnEnd();
const hookResult =
'<hook_result hook_event="UserPromptSubmit">\nhook response 1\n</hook_result>\n<hook_result hook_event="UserPromptSubmit">\nhook response 2\n</hook_result>';
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "hooked input"
user: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 1\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 2\\n</hook_result>"
`);
expect(events).toContainEqual(
expect.objectContaining({
event: 'hook.result',
args: expect.objectContaining({
hookEvent: 'UserPromptSubmit',
content: 'hook response 1\n\nhook response 2',
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: expect.objectContaining({ delta: 'model saw original prompt only' }),
}),
);
expect(ctx.agent.context.data().history).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'hooked input' }],
toolCalls: [],
origin: { kind: 'user' },
},
{
role: 'user',
content: [{ type: 'text', text: hookResult }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
},
{
role: 'assistant',
content: [{ type: 'text', text: 'model saw original prompt only' }],
toolCalls: [],
},
]);
});
it('projects structured UserPromptSubmit stdout', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
matcher: 'hooked input',
command: 'node -e "process.stdout.write(\'{}\')"',
},
{
event: 'UserPromptSubmit',
matcher: 'hooked input',
command: 'node -e "process.stdout.write(JSON.stringify({hookSpecificOutput:{}}))"',
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'model saw original prompt only' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hooked input' }] });
const events = await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "hooked input"
user: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\n{}\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\n{\\"hookSpecificOutput\\":{}}\\n</hook_result>"
`);
expect(events).toContainEqual(
expect.objectContaining({
event: 'hook.result',
args: expect.objectContaining({
hookEvent: 'UserPromptSubmit',
content: '{}\n\n{"hookSpecificOutput":{}}',
}),
}),
);
expect(ctx.agent.context.data().history).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'hooked input' }],
toolCalls: [],
origin: { kind: 'user' },
},
{
role: 'user',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\n{}\n</hook_result>\n<hook_result hook_event="UserPromptSubmit">\n{"hookSpecificOutput":{}}\n</hook_result>',
},
],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
},
{
role: 'assistant',
content: [{ type: 'text', text: 'model saw original prompt only' }],
toolCalls: [],
},
]);
});
it('stops the turn when a UserPromptSubmit hook blocks', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
matcher: 'bad words',
command: 'node -e "process.stderr.write(\'no profanity\'); process.exit(2)"',
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'bad words here' }] });
const events = await ctx.untilTurnEnd();
const hookResult = '<hook_result hook_event="UserPromptSubmit">\nno profanity\n</hook_result>';
expect(ctx.llmCalls).toHaveLength(0);
expect(events).toContainEqual(
expect.objectContaining({
event: 'hook.result',
args: expect.objectContaining({
hookEvent: 'UserPromptSubmit',
content: 'no profanity',
blocked: true,
}),
}),
);
expect(ctx.agent.context.data().history).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'bad words here' }],
toolCalls: [],
origin: { kind: 'user' },
},
{
role: 'assistant',
content: [{ type: 'text', text: hookResult }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true },
},
]);
ctx.mockNextResponse({ type: 'text', text: 'safe answer' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'safe followup' }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "bad words here"
assistant: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nno profanity\\n</hook_result>"
user: text "safe followup"
`);
});
it('cancels while waiting for a UserPromptSubmit hook without appending stale output', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
command: 'node -e "setTimeout(() => process.stdout.write(\\"late hook\\"), 250)"',
timeout: 5,
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hook will sleep' }] });
await ctx.rpc.cancel({ turnId: 0 });
const events = await ctx.untilTurnEnd();
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'cancelled' }),
}),
);
expect(events).not.toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: expect.objectContaining({ delta: expect.stringContaining('late hook') }),
}),
);
expect(ctx.agent.context.data().history).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'hook will sleep' }],
toolCalls: [],
origin: { kind: 'user' },
},
]);
});
it('uses a Stop hook block reason as a one-shot turn continuation', async () => {
const hookEngine = new HookEngine([
{
event: 'Stop',
command: 'node -e "process.stderr.write(\'continue from hook\'); process.exit(2)"',
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'First answer.' });
ctx.mockNextResponse({ type: 'text', text: 'Second answer.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(2);
const stopHookMessage = {
role: 'user',
content: [
{
type: 'text',
text: 'continue from hook',
},
],
toolCalls: [],
origin: { kind: 'system_trigger', name: 'stop_hook' },
};
const llmStopHookMessage = {
role: 'user',
content: [
{
type: 'text',
text: 'continue from hook',
},
],
toolCalls: [],
};
expect(JSON.stringify(ctx.agent.context.data().history)).toContain('continue from hook');
expect(ctx.agent.context.data().history).toContainEqual(stopHookMessage);
expect(ctx.llmCalls[1]?.history).toContainEqual(llmStopHookMessage);
expect(JSON.stringify(ctx.agent.context.data().history)).toContain('Second answer.');
});
it('cancels while waiting for a Stop hook', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-stop-hook-'));
const marker = join(dir, 'started');
const script = [
"const fs=require('node:fs');",
`fs.writeFileSync(${JSON.stringify(marker)}, 'started');`,
"setTimeout(() => process.stderr.write('late stop hook'), 250);",
].join('');
const hookEngine = new HookEngine([
{
event: 'Stop',
command: `node -e ${JSON.stringify(script)}`,
timeout: 5,
},
]);
const ctx = testAgent({ hookEngine });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'Answer before stop hook.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await waitForFile(marker);
await ctx.rpc.cancel({ turnId: 0 });
const events = await ctx.untilTurnEnd();
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'cancelled' }),
}),
);
expect(ctx.llmCalls).toHaveLength(1);
expect(JSON.stringify(ctx.agent.context.data().history)).not.toContain('late stop hook');
});
it('cancels while waiting for a PreToolUse hook inside permission evaluation', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-pre-tool-hook-'));
const marker = join(dir, 'started');
const script = [
"const fs=require('node:fs');",
`fs.writeFileSync(${JSON.stringify(marker)}, 'started');`,
"setTimeout(() => process.stdout.write('late pre tool hook'), 250);",
].join('');
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute'));
const hookEngine = new HookEngine([
{
event: 'PreToolUse',
matcher: 'Bash',
command: `node -e ${JSON.stringify(script)}`,
timeout: 5,
},
]);
const ctx = testAgent({
kaos: createFakeKaos({ execWithEnv }),
hookEngine,
});
const beforeToolCall = vi.spyOn(ctx.agent.permission, 'beforeToolCall');
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'auto' });
ctx.newEvents();
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run Bash while hook sleeps' }] });
await waitForFile(marker);
await ctx.rpc.cancel({ turnId: 0 });
const events = await ctx.untilTurnEnd();
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(execWithEnv).not.toHaveBeenCalled();
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'cancelled' }),
}),
);
expect(JSON.stringify(ctx.agent.context.data().history)).not.toContain('late pre tool hook');
});
it('fires StopFailure when a turn fails', async () => {
const triggered: Array<[string, string, number]> = [];
const hookEngine = new HookEngine(
[
{
event: 'StopFailure',
matcher: 'Error',
command: 'exit 0',
},
],
{
onTriggered: (event, target, count) => {
triggered.push([event, target, count]);
},
},
);
const ctx = testAgent({ hookEngine });
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger generate failure' }] });
await ctx.untilTurnEnd();
expect(triggered).toEqual([['StopFailure', 'Error', 1]]);
});
it('fires Interrupt when the user cancels an active turn', async () => {
const triggered: Array<[string, string, number]> = [];
const hookEngine = new HookEngine(
[
{
event: 'Interrupt',
command: 'exit 0',
},
],
{
onTriggered: (event, target, count) => {
triggered.push([event, target, count]);
},
},
);
const ctx = testAgent({
hookEngine,
kaos: createCommandKaos('should-not-run'),
});
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] });
await ctx.untilApprovalRequest();
await ctx.rpc.cancel({ turnId: 0 });
await ctx.untilTurnEnd();
expect(triggered).toEqual([['Interrupt', '', 1]]);
});
it('does not fire Interrupt for a non-user (programmatic) abort', async () => {
const triggered: Array<[string, string, number]> = [];
const hookEngine = new HookEngine(
[
{
event: 'Interrupt',
command: 'exit 0',
},
],
{
onTriggered: (event, target, count) => {
triggered.push([event, target, count]);
},
},
);
const ctx = testAgent({
hookEngine,
kaos: createCommandKaos('should-not-run'),
});
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] });
await ctx.untilApprovalRequest();
// A programmatic abort (e.g. a subagent deadline timeout) carries a plain
// AbortError as its reason, not a UserCancellationError, so it must not be
// reported as a user interrupt.
ctx.agent.turn.cancel(0, abortError());
await ctx.untilTurnEnd();
expect(triggered).toEqual([]);
});
it('resolves the latest request-scoped OAuth auth before each generation', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
const tokens = ['first-turn-token', 'second-turn-token'];
const oauthOptions = oauthAgentOptions(async (options) => {
tokenCalls.push(options?.force);
const token = tokens.shift();
if (token === undefined) throw new Error('unexpected token request');
return token;
});
const generate: GenerateFn = async (
_provider,
_system,
_tools,
_history,
callbacks,
options,
) => {
const apiKey = options?.auth?.apiKey ?? '<missing>';
authKeys.push(apiKey);
const text = `Generated with ${apiKey}`;
await callbacks?.onMessagePart?.({ type: 'text', text });
return textResult(text);
};
const ctx = testAgent({ ...oauthOptions, generate });
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
const firstEvents = await ctx.untilTurnEnd();
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello again' }] });
const secondEvents = await ctx.untilTurnEnd();
expect(authKeys).toEqual(['first-turn-token', 'second-turn-token']);
expect(tokenCalls).toEqual([undefined, undefined]);
expect(firstEvents).toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: { turnId: 0, delta: 'Generated with first-turn-token' },
}),
);
expect(secondEvents).toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: { turnId: 1, delta: 'Generated with second-turn-token' },
}),
);
expect(firstEvents).not.toContainEqual(
expect.objectContaining({ event: 'turn.step.interrupted' }),
);
expect(secondEvents).not.toContainEqual(
expect.objectContaining({ event: 'turn.step.interrupted' }),
);
});
it('emits LLM stream timing on step completion', async () => {
const ctx = testAgent();
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'timed answer' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
await ctx.untilTurnEnd();
const stepCompleted = ctx.allEvents.find(
(event) => event.type === '[rpc]' && event.event === 'turn.step.completed',
);
expect(stepCompleted?.args).toMatchObject({
llmFirstTokenLatencyMs: expect.any(Number),
llmStreamDurationMs: expect.any(Number),
});
});
it('logs LLM request metadata without message bodies', async () => {
const { logger, entries } = captureLogs();
const ctx = testAgent({ log: logger });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({
input: [{ type: 'text', text: 'secret prompt body should stay out of logs' }],
});
await ctx.untilTurnEnd();
const configLogs = entries.filter((entry) => entry.message === 'llm config');
expect(configLogs).toHaveLength(1);
const configPayload = configLogs[0]?.payload as Record<string, unknown>;
expect(configPayload).toMatchObject({
turnStep: '0.1',
provider: 'kimi',
model: 'mock-model',
modelAlias: 'mock-model',
toolCount: 0,
});
expect(configPayload['systemPromptChars']).toEqual(expect.any(Number));
const requestLogs = entries.filter((entry) => entry.message === 'llm request');
expect(requestLogs).toHaveLength(1);
const payload = requestLogs[0]?.payload as Record<string, unknown>;
expect(payload).toMatchObject({
turnStep: '0.1',
});
expect(payload).not.toHaveProperty('estimatedInputTokens');
expect(payload).not.toHaveProperty('turnId');
expect(payload).not.toHaveProperty('step');
expect(payload).not.toHaveProperty('attempt');
expect(payload).not.toHaveProperty('maxAttempts');
expect(payload).not.toHaveProperty('stepUuid');
expect(payload).not.toHaveProperty('model');
expect(payload).not.toHaveProperty('provider');
expect(payload).not.toHaveProperty('modelAlias');
expect(payload).not.toHaveProperty('thinkingEffort');
expect(payload).not.toHaveProperty('systemPromptChars');
expect(payload).not.toHaveProperty('partialMessageCount');
expect(payload).not.toHaveProperty('messageCount');
expect(payload).not.toHaveProperty('toolCallCount');
expect(payload).not.toHaveProperty('toolCount');
expect(payload).not.toHaveProperty('systemPromptHash');
expect(payload).not.toHaveProperty('toolsHash');
expect(payload).not.toHaveProperty('messageRoles');
expect(payload).not.toHaveProperty('contentPartTypes');
expect(payload).not.toHaveProperty('toolNames');
expect(payload).not.toHaveProperty('history');
expect(payload).not.toHaveProperty('systemPrompt');
expect(JSON.stringify(entries)).not.toContain('secret prompt body should stay out of logs');
});
it('does not repeat unchanged LLM config metadata', async () => {
const { logger, entries } = captureLogs();
const ctx = testAgent({ log: logger });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'first' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] });
await ctx.untilTurnEnd();
ctx.mockNextResponse({ type: 'text', text: 'second' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] });
await ctx.untilTurnEnd();
expect(entries.filter((entry) => entry.message === 'llm config')).toHaveLength(1);
expect(entries.filter((entry) => entry.message === 'llm request')).toHaveLength(2);
});
it('logs changed LLM config when same-size system prompt content changes', async () => {
const { logger, entries } = captureLogs();
const ctx = testAgent({ log: logger });
ctx.configure();
ctx.agent.config.update({ systemPrompt: 'alpha' });
ctx.mockNextResponse({ type: 'text', text: 'first' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] });
await ctx.untilTurnEnd();
ctx.agent.config.update({ systemPrompt: 'bravo' });
ctx.mockNextResponse({ type: 'text', text: 'second' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] });
await ctx.untilTurnEnd();
const configPayloads = entries
.filter((entry) => entry.message === 'llm config')
.map((entry) => entry.payload as Record<string, unknown>);
expect(configPayloads).toHaveLength(2);
expect(configPayloads.map((payload) => payload['systemPromptChars'])).toEqual([5, 5]);
for (const payload of configPayloads) {
expect(payload).not.toHaveProperty('systemPromptHash');
expect(payload).not.toHaveProperty('toolsHash');
}
});
it('does not log estimated LLM request tokens when tools are present', async () => {
const { logger, entries } = captureLogs();
const ctx = testAgent({ log: logger });
ctx.configure();
await ctx.rpc.setActiveTools({ names: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'use bash' }] });
await ctx.untilTurnEnd();
const input = ctx.llmCalls[0];
expect(input?.tools.length).toBeGreaterThan(0);
const requestPayload = entries.find((entry) => entry.message === 'llm request')?.payload as
| Record<string, unknown>
| undefined;
expect(requestPayload).not.toHaveProperty('estimatedInputTokens');
});
it('classifies OAuth resolver connection failures as provider connection errors without retrying', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const oauthOptions = oauthAgentOptions(async (options) => {
tokenCalls.push(options?.force);
throw new KimiError(
ErrorCodes.PROVIDER_CONNECTION_ERROR,
'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed',
);
});
const generate = vi.fn<GenerateFn>();
const ctx = testAgent({ ...oauthOptions, generate });
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello after token expiry' }] });
const events = await ctx.untilTurnEnd();
expect(tokenCalls).toEqual([undefined]);
expect(generate).not.toHaveBeenCalled();
expect(events).not.toContainEqual(expect.objectContaining({ event: 'assistant.delta' }));
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: ErrorCodes.PROVIDER_CONNECTION_ERROR,
message: expect.stringContaining('fetch failed'),
retryable: true,
}),
}),
}),
);
});
it('classifies explicit OAuth login-required resolver failures as auth errors', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const oauthOptions = oauthAgentOptions(async (options) => {
tokenCalls.push(options?.force);
throw new KimiError(ErrorCodes.AUTH_LOGIN_REQUIRED, 'not logged in');
});
const generate = vi.fn<GenerateFn>();
const ctx = testAgent({ ...oauthOptions, generate });
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello after token expiry' }] });
const events = await ctx.untilTurnEnd();
expect(tokenCalls).toEqual([undefined]);
expect(generate).not.toHaveBeenCalled();
expect(events).not.toContainEqual(expect.objectContaining({ event: 'assistant.delta' }));
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: ErrorCodes.AUTH_LOGIN_REQUIRED,
retryable: false,
}),
}),
}),
);
});
it('honors configured maxStepsPerTurn in agent turns', async () => {
const ctx = testAgent({
initialConfig: {
providers: {},
loopControl: { maxStepsPerTurn: 1 },
},
kaos: createCommandKaos('loop-output'),
});
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
ctx.newEvents();
const bashCall: ToolCall = {
id: 'call_bash',
type: 'function',
name: 'Bash',
arguments: '{"command":"printf loop-output","timeout":60}',
};
ctx.mockNextResponse(bashCall);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command once' }] });
const events = await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(1);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: 'loop.max_steps_exceeded',
message: expect.stringContaining('config.toml'),
details: expect.objectContaining({
maxSteps: 1,
}),
}),
}),
}),
);
});
it('force-refreshes OAuth credentials and replays the request on 401', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
const oauthOptions = oauthAgentOptions(async (options) => {
tokenCalls.push(options?.force);
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
});
const generate: GenerateFn = async (
_provider,
_system,
_tools,
_history,
callbacks,
options,
) => {
const apiKey = options?.auth?.apiKey ?? '<missing>';
authKeys.push(apiKey);
if (authKeys.length === 1) throw new APIStatusError(401, 'Unauthorized', 'req-401');
const text = `Generated with ${apiKey}`;
await callbacks?.onMessagePart?.({ type: 'text', text });
return textResult(text);
};
const ctx = testAgent({ ...oauthOptions, generate });
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello after token expiry' }] });
const events = await ctx.untilTurnEnd();
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([undefined, true]);
expect(events).toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: { turnId: 0, delta: 'Generated with forced-refresh-token' },
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'completed' }),
}),
);
});
it('falls back to login_required when force-refresh and replay both 401', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
const oauthOptions = oauthAgentOptions(
async (options) => {
tokenCalls.push(options?.force);
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
},
['image_in', 'video_in', 'tool_use'],
);
const generate: GenerateFn = async (
_provider,
_system,
_tools,
_history,
_callbacks,
options,
) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
throw new APIStatusError(401, 'Unauthorized', 'req-401');
};
const ctx = testAgent({ ...oauthOptions, generate });
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
const events = await ctx.untilTurnEnd();
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([undefined, true]);
expect(events).not.toContainEqual(expect.objectContaining({ event: 'assistant.delta' }));
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: 'auth.login_required',
details: expect.objectContaining({
statusCode: 401,
requestId: 'req-401',
}),
}),
}),
}),
);
});
it('keeps non-OAuth provider 401 as provider auth error', async () => {
const generate: GenerateFn = async () => {
throw new APIStatusError(401, 'Unauthorized', 'req-api-key-401');
};
const ctx = testAgent({ generate });
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
const events = await ctx.untilTurnEnd();
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: 'provider.auth_error',
details: expect.objectContaining({
statusCode: 401,
requestId: 'req-api-key-401',
}),
}),
}),
}),
);
});
it.each<ApiErrorTelemetryCase>([
{
name: '429 status',
createError: () => new APIStatusError(429, 'Rate limited', 'req-429'),
errorType: 'rate_limit',
statusCode: 429,
},
{
name: '401 status',
createError: () => new APIStatusError(401, 'Unauthorized', 'req-401'),
errorType: 'auth',
statusCode: 401,
},
{
name: '403 status',
createError: () => new APIStatusError(403, 'Forbidden', 'req-403'),
errorType: 'auth',
statusCode: 403,
},
{
name: '500 status',
createError: () => new APIStatusError(500, 'Internal server error', 'req-500'),
errorType: '5xx_server',
statusCode: 500,
},
{
name: '400 status',
createError: () => new APIStatusError(400, 'Bad request', 'req-400'),
errorType: '4xx_client',
statusCode: 400,
},
{
name: 'context overflow status',
createError: () => new APIStatusError(422, 'Maximum context window exceeded', 'req-422'),
errorType: 'context_overflow',
statusCode: 422,
},
{
name: 'context overflow token count status',
createError: () =>
new APIStatusError(
400,
'input token count 131072 exceeds the maximum number of tokens allowed',
'req-token-count',
),
errorType: 'context_overflow',
statusCode: 400,
},
{
name: 'connection error',
createError: () => new APIConnectionError('socket hang up'),
errorType: 'network',
},
{
name: 'timeout error',
createError: () => new APITimeoutError('request timed out'),
errorType: 'timeout',
},
{
name: 'empty response error',
createError: () => new APIEmptyResponseError('empty response'),
errorType: 'empty_response',
},
{
name: 'generic step error',
createError: () => new Error('unexpected step failure'),
errorType: 'other',
},
])('tracks api_error telemetry for $name', async ({ createError, errorType, statusCode }) => {
const records: TelemetryRecord[] = [];
const generate: GenerateFn = async () => {
throw createError();
};
const ctx = testAgent({
generate,
...singleAttemptAgentOptions(),
telemetry: recordingTelemetry(records),
});
ctx.configure();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'trigger provider error' }] });
await ctx.untilTurnEnd();
const expectedProperties: Record<string, unknown> = {
error_type: errorType,
model: 'mock-model',
retryable: expect.any(Boolean),
duration_ms: expect.any(Number),
};
if (statusCode !== undefined) {
expectedProperties['status_code'] = statusCode;
}
const record = records.find((candidate) => candidate.event === 'api_error');
expect(record).toEqual({
event: 'api_error',
properties: expect.objectContaining(expectedProperties),
});
if (statusCode === undefined) {
expect(record?.properties).not.toHaveProperty('status_code');
}
});
it('keeps transient retry handling with request-scoped OAuth auth', async () => {
const { logger, entries } = captureLogs();
const authKeys: string[] = [];
const oauthOptions = oauthAgentOptions(async () => 'fresh-token');
const generate: GenerateFn = async (
_provider,
_system,
_tools,
_history,
callbacks,
options,
) => {
options?.onRequestStart?.();
authKeys.push(options?.auth?.apiKey ?? '<missing>');
if (authKeys.length === 1) {
throw new APIConnectionError('socket hang up');
}
await callbacks?.onMessagePart?.({ type: 'text', text: 'Recovered after retry' });
options?.onStreamEnd?.();
return textResult('Recovered after retry');
};
const ctx = testAgent({ ...oauthOptions, generate, log: logger });
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
const events = await ctx.untilTurnEnd();
expect(authKeys).toEqual(['fresh-token', 'fresh-token']);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.step.retrying',
args: expect.objectContaining({
failedAttempt: 1,
nextAttempt: 2,
errorName: 'APIConnectionError',
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'assistant.delta',
args: { turnId: 0, delta: 'Recovered after retry' },
}),
);
const requestLogs = entries.filter((entry) => entry.message === 'llm request');
const payloads = requestLogs.map((entry) => entry.payload as Record<string, unknown>);
expect(payloads[0]).toMatchObject({ turnStep: '0.1' });
expect(payloads[0]).not.toHaveProperty('attempt');
expect(payloads[1]).toMatchObject({ turnStep: '0.1', attempt: '2/3' });
});
it('force-refreshes OAuth credentials on video upload 401 and falls back to login_required when replay 401', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
const oauthOptions = oauthAgentOptions(
async (options) => {
tokenCalls.push(options?.force);
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
},
['image_in', 'video_in', 'tool_use'],
);
const provider = {
uploadVideo: vi.fn().mockImplementation(async (_input, options) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
throw new APIStatusError(401, 'Unauthorized', 'req-upload-401');
}),
} as unknown as ChatProvider;
const ctx = testAgent({
...oauthOptions,
kaos: createVideoKaos(),
});
ctx.agent.config.update({
cwd: process.cwd(),
modelAlias: 'kimi-code',
systemPrompt: 'test system prompt',
thinkingLevel: 'off',
});
Object.defineProperty(ctx.agent.config, 'provider', {
configurable: true,
get: () => provider,
});
ctx.agent.tools.initializeBuiltinTools();
ctx.agent.tools.setActiveTools(['ReadMediaFile']);
const tool = ctx.agent.tools.loopTools.find((candidate) => candidate.name === 'ReadMediaFile');
if (tool === undefined) throw new Error('ReadMediaFile tool was not initialized');
const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'call_media',
args: { path: '/workspace/sample.mp4' },
signal: new AbortController().signal,
});
expect(result.isError).toBe(true);
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([undefined, true]);
expect(result.output).toContain('OAuth provider credentials were rejected');
expect(result.output).toContain('Send /login to login');
});
it('cancels an active turn', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({
kaos: createCommandKaos('should-not-run'),
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] });
expect(await ctx.untilApprovalRequest()).toMatchInlineSnapshot(`
[wire] turn.prompt { "input": [ { "type": "text", "text": "Run a command" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Run a command" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will run Bash." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will run Bash." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Bash
messages:
user: text "Run a command"
`);
records.length = 0;
await ctx.rpc.cancel({ turnId: 0 });
expect(records).toContainEqual({
event: 'cancel',
properties: { from: 'streaming' },
});
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
`);
expect(records).toContainEqual({
event: 'tool_call',
properties: expect.objectContaining({
tool_name: 'Bash',
outcome: 'cancelled',
dup_type: 'normal',
duration_ms: expect.any(Number),
}),
});
await ctx.expectResumeMatches();
});
it('buffers steer input and includes it in the same turn after approval', async () => {
const bashCall: ToolCall = {
type: 'function',
id: 'call_bash',
name: 'Bash',
arguments: '{"command":"printf approved","timeout":60}',
};
const ctx = testAgent({
kaos: createCommandKaos('approved'),
});
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will ask first.' }, bashCall);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run Bash, then listen' }] });
const approval = await ctx.takeApprovalRequest();
expect(approval.events).toMatchInlineSnapshot(`
[wire] turn.prompt { "input": [ { "type": "text", "text": "Run Bash, then listen" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Run Bash, then listen" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will ask first." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf approved\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will ask first." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Bash
messages:
user: text "Run Bash, then listen"
`);
expect(ctx.llmCalls).toHaveLength(1);
await ctx.rpc.steer({ input: [{ type: 'text', text: 'Also mention the steer.' }] });
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.newEvents()).toMatchInlineSnapshot(`[wire] turn.steer { "input": [ { "type": "text", "text": "Also mention the steer." } ], "origin": { "kind": "user" }, "time": "<time>" }`);
ctx.mockNextResponse({ type: 'text', text: 'Approved, and I saw the steer.' });
approval.respond({
decision: 'approved',
selectedLabel: 'approve',
});
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "approved" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "approved" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "approved" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 29, "maxContextTokens": 1000000, "contextUsage": 0.000029, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Also mention the steer." } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-3>", "turnId": "0", "step": 2 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-3>" }
[emit] assistant.delta { "turnId": 0, "delta": "Approved, and I saw the steer." }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "stepUuid": "<uuid-3>", "part": { "type": "text", "text": "Approved, and I saw the steer." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-3>", "turnId": "0", "step": 2, "usage": { "inputOther": 39, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-3>", "usage": { "inputOther": 39, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 39, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 50, "maxContextTokens": 1000000, "contextUsage": 0.00005, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 46, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 46, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 46, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
messages:
<last>
assistant: text "I will ask first." calls call_bash:Bash { "command": "printf approved", "timeout": 60 }
tool[call_bash]: text "approved"
user: text "Also mention the steer."
`);
expect(ctx.llmCalls).toHaveLength(2);
await ctx.expectResumeMatches();
});
it('rejects a non-steer prompt while a turn is active', async () => {
const ctx = testAgent({ kaos: createCommandKaos('should-not-run') });
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] });
expect(await ctx.untilApprovalRequest()).toMatchInlineSnapshot(`
[wire] turn.prompt { "input": [ { "type": "text", "text": "Start the active turn" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Start the active turn" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will wait for approval." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will wait for approval." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Bash
messages:
user: text "Start the active turn"
`);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'This should not start a new turn' }] });
expect(ctx.newEvents()).toMatchInlineSnapshot(`
[wire] turn.prompt { "input": [ { "type": "text", "text": "This should not start a new turn" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] error { "code": "turn.agent_busy", "message": "Cannot launch a new turn while another turn (ID 0) is active", "details": { "turnId": 0 }, "retryable": true }
`);
await ctx.rpc.cancel({ turnId: 0 });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
`);
await ctx.expectResumeMatches();
});
});
const abortableGenerate: GenerateFn = async (
_chat,
_systemPrompt,
_tools,
_history,
_callbacks,
options,
) => {
await new Promise<void>((_resolve, reject) => {
const rejectAbort = () => {
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
if (options?.signal?.aborted === true) {
rejectAbort();
return;
}
options?.signal?.addEventListener('abort', rejectAbort, { once: true });
});
throw new Error('abortableGenerate unexpectedly completed');
};
function eventIndex(
ctx: Pick<ReturnType<typeof testAgent>, 'allEvents'>,
type: string,
event: string,
): number {
return ctx.allEvents.findIndex((entry) => entry.type === type && entry.event === event);
}
function bashCall(): ToolCall {
return bashCallWithId('call_bash', 'printf should-not-run');
}
function bashCallWithId(id: string, command: string): ToolCall {
return {
type: 'function',
id,
name: 'Bash',
arguments: JSON.stringify({ command, timeout: 60 }),
};
}
function agentSwarmCall(): ToolCall {
return {
type: 'function',
id: 'call_swarm',
name: 'AgentSwarm',
arguments: JSON.stringify({
description: 'Review files',
prompt_template: 'Review {{item}}',
items: ['src/a.ts', 'src/b.ts'],
}),
};
}
function mockSubagentHost<T extends Partial<SessionSubagentHost>>(
host: T,
): T & SessionSubagentHost {
return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T &
SessionSubagentHost;
}
interface ApiErrorTelemetryCase {
readonly name: string;
readonly createError: () => Error;
readonly errorType: string;
readonly statusCode?: number;
}
function singleAttemptAgentOptions(): Pick<TestAgentOptions, 'initialConfig'> {
return {
initialConfig: {
providers: {},
loopControl: { maxRetriesPerStep: 1 },
},
};
}
const MP4_HEADER = Buffer.concat([
Buffer.from([0x00, 0x00, 0x00, 0x18]),
Buffer.from('ftyp'),
Buffer.from('mp42'),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from('mp42isom'),
]);
const DEFAULT_MEDIA_STAT = {
stMode: 0o100644,
stIno: 0,
stDev: 0,
stNlink: 1,
stUid: 0,
stGid: 0,
stSize: MP4_HEADER.length,
stAtime: 0,
stMtime: 0,
stCtime: 0,
};
function createVideoKaos(): Kaos {
return createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue(DEFAULT_MEDIA_STAT),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(MP4_HEADER),
});
}
async function waitForFile(path: string): Promise<void> {
for (let i = 0; i < 100; i++) {
if (existsSync(path)) return;
await delay(10);
}
throw new Error(`Timed out waiting for ${path}`);
}
function mediaCapabilities(): ModelCapability {
return {
image_in: true,
video_in: true,
audio_in: false,
thinking: false,
tool_use: true,
max_context_tokens: 1_000_000,
};
}
function oauthAgentOptions(
getAccessToken: (options?: { readonly force?: boolean }) => Promise<string>,
capabilities?: readonly string[] | undefined,
): Pick<TestAgentOptions, 'initialConfig' | 'providerManagerOverrides'> {
return {
initialConfig: {
defaultModel: 'kimi-code',
providers: {
'managed:kimi-code': {
type: 'vertexai',
baseUrl: 'https://api.example/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
},
models: {
'kimi-code': {
provider: 'managed:kimi-code',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
capabilities: capabilities === undefined ? undefined : [...capabilities],
},
},
},
providerManagerOverrides: {
resolveOAuthTokenProvider: vi.fn(() => ({ getAccessToken })),
},
};
}
function textResult(text: string): Awaited<ReturnType<GenerateFn>> {
return {
id: 'mock-oauth-retry',
message: {
role: 'assistant',
content: [{ type: 'text', text }],
toolCalls: [],
},
usage: {
inputOther: 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'completed',
rawFinishReason: 'stop',
};
}