kimi-code/packages/agent-core/test/agent/harness/agent.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

1119 lines
33 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { Readable, type Writable } from 'node:stream';
import { createControlledPromise } from '@antfu/utils';
import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
import type { ModelCapability, ProviderConfig } from '@moonshot-ai/kosong';
import { expect, onTestFinished, vi } from 'vitest';
import {
Agent,
type AgentOptions,
type AgentRecord,
type AgentRecordPersistence,
} from '../../../src/agent';
import type { CompactionStrategy } from '../../../src/agent/compaction';
import type { GoalMode } from '../../../src/agent/goal';
import type { ApprovalResponse } from '../../../src/agent/permission';
import {
AGENT_WIRE_PROTOCOL_VERSION,
InMemoryAgentRecordPersistence,
} from '../../../src/agent/records';
import type { KimiConfig } from '../../../src/config';
import type { ExecutableToolResult } from '../../../src/loop';
import type { Logger } from '../../../src/logging';
import { ProviderManager } from '../../../src/session/provider-manager';
import type { QuestionResult, RPCCallOptions, SDKAgentRPC } from '../../../src/rpc';
import type { AgentAPI } from '../../../src/rpc/core-api';
import type { ToolServices } from '../../../src/tools/support/services';
import type { TelemetryClient } from '../../../src/telemetry';
import type { PromisifyMethods } from '../../../src/utils/types';
import { createFakeKaos } from '../../tools/fixtures/fake-kaos';
import { testKaos } from '../../fixtures/test-kaos';
import { createScriptedGenerate } from './scripted-generate';
import {
DEFAULT_TEST_SYSTEM_PROMPT,
eventSnapshot,
type EventSnapshotEntry,
type RpcSnapshotEntry,
type WireSnapshotEntry,
} from './snapshots';
const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
const MOCK_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
model: 'mock-model',
} as const;
const RPC_RESPONSE = Symbol('rpcResponse');
type RpcPromise<T> = Promise<T> & {
resolve(value: T): void;
reject(reason?: unknown): void;
};
type RpcLogEntry = RpcSnapshotEntry & {
readonly [RPC_RESPONSE]?: RpcPromise<unknown>;
};
type PromiseAgentAPI = PromisifyMethods<AgentAPI>;
type GenerateFn = NonNullable<AgentOptions['generate']>;
type TestToolResult = ExecutableToolResult & {
readonly content?: unknown;
};
interface ResumeStateSnapshot {
readonly background: ReturnType<Agent['background']['list']>;
readonly config: {
readonly cwd: string;
readonly provider: ProviderConfig | undefined;
readonly profileName: string | undefined;
readonly thinkingLevel: string;
readonly systemPrompt: string;
};
readonly context: ReturnType<Agent['context']['data']>;
readonly permission: ReturnType<Agent['permission']['data']>;
readonly tools: ReturnType<Agent['tools']['data']>;
readonly toolStore: ReturnType<Agent['tools']['storeData']>;
readonly usage: ReturnType<Agent['usage']['data']>;
}
export interface TestAgentOptions {
readonly kaos?: Kaos | undefined;
readonly runtime?: ToolServices | undefined;
readonly compactionStrategy?: CompactionStrategy | undefined;
readonly microCompaction?: AgentOptions['microCompaction'];
readonly generate?: GenerateFn | undefined;
readonly hookEngine?: AgentOptions['hookEngine'];
readonly type?: AgentOptions['type'];
readonly permission?: AgentOptions['permission'];
readonly goal?: GoalMode;
readonly providerManager?: ProviderManager;
readonly initialConfig?: KimiConfig;
readonly providerManagerOverrides?: Omit<ConstructorParameters<typeof ProviderManager>[0], 'config'>;
readonly sessionId?: string;
readonly subagentHost?: AgentOptions['subagentHost'];
readonly onEvent?: ((event: AgentRecord) => AgentRecord | undefined) | undefined;
readonly persistence?: AgentRecordPersistence | undefined;
readonly homedir?: AgentOptions['homedir'];
readonly telemetry?: TelemetryClient | undefined;
readonly log?: Logger;
readonly experimentalFlags?: AgentOptions['experimentalFlags'];
}
interface ConfigureOptions {
readonly tools?: readonly string[] | undefined;
readonly provider?: ProviderConfig | undefined;
readonly modelCapabilities?: ModelCapability | undefined;
}
export type TestAgentContext = AgentTestContext;
export function createCommandKaos(stdout: string): Kaos {
function createProcess(): KaosProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([stdout]),
stderr: Readable.from(['']),
pid: 42,
exitCode: 0,
wait: vi.fn().mockResolvedValue(0),
kill: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
};
}
return createFakeKaos({
execWithEnv: vi.fn().mockImplementation(async () => createProcess()),
mkdir: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn(async (_path: string, content: string) => content.length),
});
}
export function testAgent(options: TestAgentOptions = {}): AgentTestContext {
return new AgentTestContext(options);
}
export class AgentTestContext {
private readonly options: TestAgentOptions;
private readonly scriptedGenerate = createScriptedGenerate();
private readonly recordHistory: AgentRecord[] = [];
private suppressWireSnapshot = false;
private lastEventCount = 0;
private readonly uuidLabels = new Map<string, string>();
readonly emitter = new EventEmitter();
readonly allEvents: EventSnapshotEntry[] = [];
readonly agent: Agent;
readonly rpc: PromiseAgentAPI;
readonly llmCalls = this.scriptedGenerate.calls;
readonly lastLlmInput = this.scriptedGenerate.lastInput;
readonly llmInputs = this.scriptedGenerate.inputs;
readonly mockNextResponse = this.scriptedGenerate.mockNextResponse;
readonly mockNextProviderResponse = this.scriptedGenerate.mockNextProviderResponse;
private kimiConfig: KimiConfig;
constructor(options: TestAgentOptions = {}) {
this.options = options;
this.emitter.on('error', () => {});
this.kimiConfig = options.initialConfig ?? emptyConfig();
const providerManager = options.providerManager ?? new ProviderManager({
config: () => this.kimiConfig,
...(options.sessionId !== undefined ? { promptCacheKey: options.sessionId } : {}),
...options.providerManagerOverrides,
});
const kaos = options.kaos ?? testKaos;
const toolServices = options.runtime;
const persistence = this.wrapPersistence(
options.persistence ?? new InMemoryAgentRecordPersistence(),
);
this.agent = new Agent({
kaos,
toolServices,
config: this.kimiConfig,
rpc: this.createRpcProxy(),
homedir: options.homedir,
persistence,
generate: options.generate ?? this.scriptedGenerate.generate,
compactionStrategy: options.compactionStrategy,
microCompaction: options.microCompaction,
modelProvider: providerManager,
subagentHost: options.subagentHost,
type: options.type,
permission: options.permission,
hookEngine: options.hookEngine,
telemetry: options.telemetry,
log: options.log,
experimentalFlags: options.experimentalFlags,
});
if (options.goal !== undefined) {
(this.agent as unknown as { goal: GoalMode }).goal = options.goal;
}
this.rpc = this.createPromiseAgentApi(this.agent);
// The Agent constructor now eagerly binds a SIGUSR1 listener via
// CronManager.start(). Without per-test cleanup, every Agent built
// by this harness leaks one listener — Node prints a
// MaxListenersExceededWarning once the suite crosses 10 agents.
// onTestFinished is a vitest API callable from non-hook scopes, so
// we register cleanup transparently without forcing every test to
// remember an afterEach.
onTestFinished(async () => {
await this.agent.cron?.stop();
});
}
configure({
tools = [],
provider = MOCK_PROVIDER,
modelCapabilities,
}: ConfigureOptions = {}): void {
this.configureRuntimeModel(provider, modelCapabilities);
this.agent.config.update({
cwd: process.cwd(),
modelAlias: provider.model,
systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT,
thinkingLevel: 'off',
});
if (tools.length > 0) {
void this.rpc.setActiveTools({ names: [...tools] });
}
this.lastEventCount = this.allEvents.length;
}
configureRuntimeModel(
provider: ProviderConfig,
modelCapabilities?: ModelCapability | undefined,
): void {
if (this.options.providerManager === undefined) {
this.kimiConfig = configWithProvider(this.kimiConfig, provider, modelCapabilities);
}
this.agent.config.update({ modelAlias: provider.model });
}
newEvents(): ReturnType<typeof eventSnapshot> {
const events = this.allEvents.slice(this.lastEventCount);
this.lastEventCount = this.allEvents.length;
return eventSnapshot(events, this.uuidLabels);
}
untilTurnEnd(): Promise<ReturnType<typeof eventSnapshot>> {
return this.takeUntilRpc('turn.ended').then(({ events }) => events);
}
untilApprovalRequest(): Promise<ReturnType<typeof eventSnapshot>> {
return this.takeUntilRpc('requestApproval').then(({ events }) => events);
}
async takeApprovalRequest(): Promise<{
events: ReturnType<typeof eventSnapshot>;
respond(response: ApprovalResponse): void;
}> {
const { event, events } = await this.takeUntilRpc('requestApproval');
return {
events,
respond: (response) => {
this.resolveRpcRequest(event, response);
},
};
}
async untilApproval(approved: boolean): Promise<ReturnType<typeof eventSnapshot>> {
const { event, events } = await this.takeUntilRpc('requestApproval');
this.resolveRpcRequest(event, {
decision: approved ? 'approved' : 'rejected',
selectedLabel: approved ? 'approve' : 'reject',
} satisfies ApprovalResponse);
return events;
}
untilQuestionRequest(): Promise<ReturnType<typeof eventSnapshot>> {
return this.takeUntilRpc('requestQuestion').then(({ events }) => events);
}
async untilQuestion(result: QuestionResult): Promise<ReturnType<typeof eventSnapshot>> {
const { event, events } = await this.takeUntilRpc('requestQuestion');
this.resolveRpcRequest(event, result);
return events;
}
async untilToolCall(result: TestToolResult): Promise<ReturnType<typeof eventSnapshot>> {
const { event, events } = await this.takeUntilRpc('toolCall');
this.resolveRpcRequest(event, result);
return events;
}
dispatch(event: AgentRecord): void {
this.suppressWireSnapshot = true;
try {
this.appendRecord(event);
} finally {
this.suppressWireSnapshot = false;
}
}
once(type: string): Promise<void> {
return new Promise((resolve) => {
this.emitter.once(type, () => {
resolve();
});
});
}
onceAny(types: readonly string[]): Promise<string> {
return new Promise((resolve) => {
for (const type of types) {
this.emitter.once(type, () => {
resolve(type);
});
}
});
}
appendExchange(
step: number,
userText: string,
assistantText: string,
tokenTotal: number,
): void {
const stepUuid = `step-${String(step)}`;
this.agent.context.appendUserMessage([{ type: 'text', text: userText }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: {
type: 'text',
text: assistantText,
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
usage: {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'end_turn',
},
});
}
appendAssistantText(step: number, text: string): void {
this.appendAssistantTextWithUsage(step, text);
}
appendAssistantTextWithUsage(step: number, text: string, tokenTotal?: number): void {
const stepUuid = `context-step-${String(step)}`;
const usage =
tokenTotal === undefined
? undefined
: {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
};
this.agent.context.appendUserMessage([
{ type: 'text', text: `user before step ${String(step)}` },
]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `context-part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: {
type: 'text',
text,
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
usage,
finishReason: 'end_turn',
},
});
}
appendAssistantTurn(step: number, text: string): void {
const stepUuid = `plan-injection-step-${String(step)}`;
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `plan-injection-part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: { type: 'text', text },
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
finishReason: 'end_turn',
},
});
}
appendToolExchange(): void {
const stepUuid = 'context-tool-step';
this.agent.context.appendUserMessage([{ type: 'text', text: 'lookup something' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'context-tool-part',
turnId: '',
step: 2,
stepUuid,
part: {
type: 'text',
text: 'I will call Lookup.',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'context-tool-call',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_lookup',
name: 'Lookup',
args: {
query: 'moon',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'context-tool-call',
toolCallId: 'call_lookup',
result: { output: 'lookup result' },
},
});
}
appendUnresolvedToolExchange(resolvedToolResults: 0 | 1): void {
const stepUuid = `unresolved-tool-step-${String(resolvedToolResults)}`;
this.agent.context.appendUserMessage([{ type: 'text', text: 'run unresolved tools' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
for (const [toolCallId, name] of [
['call_unresolved_one', 'LookupOne'],
['call_unresolved_two', 'LookupTwo'],
] as const) {
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '',
step: 2,
stepUuid,
toolCallId,
name,
args: {},
},
});
}
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
if (resolvedToolResults === 1) {
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_unresolved_one',
toolCallId: 'call_unresolved_one',
result: { output: 'one result' },
},
});
}
}
appendRichToolExchange(): void {
const stepUuid = 'rich-step';
this.agent.context.appendUserMessage([
{ type: 'text', text: 'inspect this image' },
{ type: 'image_url', imageUrl: { url: 'ms://image-1', id: 'image-1' } },
]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 1 },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'rich-think',
turnId: '',
step: 1,
stepUuid,
part: {
type: 'think',
think: 'checking metadata',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'rich-text',
turnId: '',
step: 1,
stepUuid,
part: {
type: 'text',
text: 'I will call Lookup.',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'rich-tool-call',
turnId: '',
step: 1,
stepUuid,
toolCallId: 'call_lookup',
name: 'Lookup',
args: {
query: 'moon',
limit: 2,
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 1,
usage: {
inputOther: 50,
output: 10,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'tool_use',
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'rich-tool-call',
toolCallId: 'call_lookup',
result: {
output: [
{ type: 'text', text: 'lookup result' },
{ type: 'video_url', videoUrl: { url: 'ms://video-1', id: 'video-1' } },
],
},
},
});
}
appendContextPartiallyResolvedParallelToolExchange(): void {
const stepUuid = 'context-partial-tool-step';
this.agent.context.appendUserMessage([{ type: 'text', text: 'run both tools' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
for (const [toolCallId, name] of [
['call_open_one', 'LookupOne'],
['call_open_two', 'LookupTwo'],
] as const) {
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '',
step: 2,
stepUuid,
toolCallId,
name,
args: {},
},
});
}
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_one',
toolCallId: 'call_open_one',
result: { output: 'one result' },
},
});
}
appendPartiallyResolvedParallelToolExchange(): void {
const stepUuid = 'partial-tool-step';
this.agent.context.appendUserMessage([{ type: 'text', text: 'run both tools' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_open_one',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_open_one',
name: 'LookupOne',
args: { query: 'one' },
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_open_two',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_open_two',
name: 'LookupTwo',
args: { query: 'two' },
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_one',
toolCallId: 'call_open_one',
result: {
output: 'one result',
},
},
});
}
compactHistory(): Array<{ readonly role: string; readonly text: string }> {
return this.agent.context.history.map((message) => ({
role: message.role,
text: message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''),
}));
}
async expectResumeMatches(): Promise<void> {
const resumed = testAgent({
kaos: createResumeNoSideEffectKaos(this.agent.config.cwd, this.agent.kaos.pathClass()),
runtime: {
urlFetcher: this.agent.toolServices?.urlFetcher,
webSearcher: this.agent.toolServices?.webSearcher,
},
providerManager: this.options.providerManager,
initialConfig: this.kimiConfig,
providerManagerOverrides: this.options.providerManagerOverrides,
generate: failOnResumeGenerate,
compactionStrategy: this.options.compactionStrategy,
microCompaction: this.options.microCompaction,
subagentHost: this.options.subagentHost,
experimentalFlags: this.options.experimentalFlags,
persistence: new InMemoryAgentRecordPersistence(
withMetadata(this.recordHistory.map(cloneRecord)),
),
});
await resumed.agent.resume();
// oxlint-disable-next-line jest/no-standalone-expect
expect(resumeStateSnapshot(resumed.agent)).toEqual(resumeStateSnapshot(this.agent));
}
private takeUntilRpc(method: string): Promise<{
event: RpcLogEntry;
events: ReturnType<typeof eventSnapshot>;
}> {
const ready = this.findRpcFromCursor(method);
if (ready !== undefined) return Promise.resolve(this.takeThrough(ready));
const promise = createControlledPromise<{
event: RpcLogEntry;
events: ReturnType<typeof eventSnapshot>;
}>();
const onEvent = () => {
const event = this.findRpcFromCursor(method);
if (event === undefined) return;
this.emitter.off('event', onEvent);
promise.resolve(this.takeThrough(event));
};
this.emitter.on('event', onEvent);
return promise;
}
private takeThrough(match: { event: RpcLogEntry; index: number }): {
event: RpcLogEntry;
events: ReturnType<typeof eventSnapshot>;
} {
const events = this.allEvents.slice(this.lastEventCount, match.index + 1);
this.lastEventCount = match.index + 1;
return {
event: match.event,
events: eventSnapshot(events, this.uuidLabels),
};
}
private findRpcFromCursor(method: string): { event: RpcLogEntry; index: number } | undefined {
const index = this.allEvents.findIndex((entry, eventIndex) => {
return eventIndex >= this.lastEventCount && entry.type === '[rpc]' && entry.event === method;
});
if (index === -1) return undefined;
const event = this.allEvents[index]!;
return { event: event as RpcLogEntry, index };
}
private recordWire(event: AgentRecord): WireSnapshotEntry {
const { type, ...args } = event;
const entry: WireSnapshotEntry = {
type: '[wire]',
event: type,
args,
};
this.allEvents.push(entry);
this.emitter.emit(type, entry);
this.emitter.emit('event', entry);
return entry;
}
private recordRpc(method: string, args: unknown, response?: RpcPromise<unknown>): RpcLogEntry {
const event: RpcLogEntry = {
type: '[rpc]',
event: method,
args,
...(response !== undefined ? { [RPC_RESPONSE]: response } : {}),
};
this.allEvents.push(event);
this.emitter.emit(method, event);
this.emitter.emit('event', event);
return event;
}
private createRpcPromise<T>(signal?: AbortSignal): RpcPromise<T> {
const promise = createControlledPromise<T>() as RpcPromise<T>;
const abort = () => {
const error = new Error('Aborted');
error.name = 'AbortError';
promise.reject(error);
};
if (signal?.aborted) {
abort();
} else {
signal?.addEventListener('abort', abort, { once: true });
}
return promise;
}
private resolveRpcRequest(event: RpcLogEntry, result: unknown): void {
const response = event[RPC_RESPONSE];
if (response === undefined) {
throw new Error(`RPC ${event.event} does not have a pending response`);
}
response.resolve(result);
}
private createRpcProxy(): SDKAgentRPC {
return new Proxy(
{},
{
get: (_target, property) => {
if (typeof property !== 'string') return;
return (payload: unknown, options?: RPCCallOptions) => {
if (property === 'emitEvent') {
const event = payload;
if (!this.isRpcEvent(event)) {
throw new TypeError('rpc.emitEvent expected an event object');
}
const { type, ...eventPayload } = event;
this.recordRpc(type, eventPayload);
return;
}
const promise = this.createRpcPromise(options?.signal);
void promise.catch(() => {});
this.recordRpc(property, payload, promise);
options?.signal?.throwIfAborted();
return promise;
};
},
},
) as SDKAgentRPC;
}
private isRpcEvent(value: unknown): value is { readonly type: string } {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as { readonly type?: unknown }).type === 'string'
);
}
private appendRecord(event: AgentRecord): void {
const records = (
this.agent as unknown as {
records: {
logRecord(record: AgentRecord): void;
restore(record: AgentRecord): void;
};
}
).records;
records.logRecord(event);
records.restore(event);
}
private wrapPersistence(persistence: AgentRecordPersistence): AgentRecordPersistence {
return {
read: () => this.readAndCapturePersistence(persistence),
append: (event) => {
this.captureRecord(event);
persistence.append(event);
},
rewrite: (records) => {
persistence.rewrite(records);
},
flush: () => persistence.flush(),
close: () => persistence.close(),
};
}
private async *readAndCapturePersistence(
persistence: AgentRecordPersistence,
): AsyncIterable<AgentRecord> {
for await (const event of persistence.read()) {
this.recordHistory.push(cloneRecord(event));
yield event;
}
}
private captureRecord(event: AgentRecord): void {
const cloned = cloneRecord(event);
this.recordHistory.push(cloned);
if (this.suppressWireSnapshot) return;
this.recordWire(cloned);
const response = this.options.onEvent?.(cloned);
if (response !== undefined) {
this.dispatch(response);
}
}
private createPromiseAgentApi(agent: Agent): PromiseAgentAPI {
const target = agent.rpcMethods;
return new Proxy(target, {
get(proxyTarget, property, receiver) {
const value = Reflect.get(proxyTarget, property, receiver);
if (typeof value !== 'function') return value;
return (payload: unknown) => {
try {
return Promise.resolve(value.call(proxyTarget, payload));
} catch (error) {
return Promise.reject(error);
}
};
},
}) as unknown as PromiseAgentAPI;
}
}
const failOnResumeGenerate: GenerateFn = async () => {
throw new Error('Resume replay unexpectedly called the LLM');
};
function createResumeNoSideEffectKaos(
initialCwd: string,
pathClass: 'posix' | 'win32',
): Kaos {
const fail = (method: string): never => {
throw new Error(`Resume replay unexpectedly called kaos.${method}`);
};
// Replay may carry `config.update({cwd})` events that route through
// `kaos.chdir(...)`; let those mutate an internal cwd field so replay
// succeeds. Actual fs I/O methods remain forbidden. `pathClass` mirrors
// the live agent's kaos so platform-conditional tool descriptions (e.g.
// Glob's Windows note) match the original in `expectResumeMatches`.
let cwd = initialCwd;
return {
name: 'resume-no-side-effects',
osEnv: TEST_OS_ENV,
pathClass: () => pathClass,
normpath: (p: string) => p,
gethome: () => '/home/test',
getcwd: () => cwd,
withCwd: (next: string) => createResumeNoSideEffectKaos(next, pathClass),
withEnv: () => createResumeNoSideEffectKaos(cwd, pathClass),
chdir: async (next: string) => {
cwd = next;
},
stat: () => fail('stat'),
iterdir: () => fail('iterdir'),
glob: () => fail('glob'),
readBytes: () => fail('readBytes'),
readText: () => fail('readText'),
readLines: () => fail('readLines'),
writeBytes: () => fail('writeBytes'),
writeText: () => fail('writeText'),
mkdir: () => fail('mkdir'),
exec: () => fail('exec'),
execWithEnv: () => fail('execWithEnv'),
};
}
function resumeStateSnapshot(agent: Agent): ResumeStateSnapshot {
return {
background: agent.background.list(false),
config: configStateSnapshot(agent),
context: resumeContextSnapshot(agent),
permission: agent.permission.data(),
tools: agent.tools.data(),
toolStore: agent.tools.storeData(),
usage: agent.usage.data(),
};
}
function resumeContextSnapshot(agent: Agent) {
const context = agent.context.data();
return {
...context,
history: context.history.filter((message) => !isSystemReminderMessage(message)),
};
}
function isSystemReminderMessage(
message: ReturnType<Agent['context']['data']>['history'][number],
): boolean {
if (message.role !== 'user') return false;
const text = message.content
.map((part) => (part.type === 'text' ? part.text : ''))
.join('')
.trimStart();
return text.startsWith('<system-reminder>');
}
function configStateSnapshot(agent: Agent): ResumeStateSnapshot['config'] {
let provider: ProviderConfig | undefined;
try {
provider = agent.config.providerConfig;
} catch {}
return {
cwd: agent.config.cwd.replaceAll('\\', '/'),
provider,
profileName: agent.config.profileName,
thinkingLevel: agent.config.thinkingLevel,
systemPrompt: agent.config.systemPrompt,
};
}
function emptyConfig(): KimiConfig {
return configWithProvider({ providers: {} }, MOCK_PROVIDER, undefined);
}
function configWithProvider(
config: KimiConfig,
provider: ProviderConfig,
modelCapabilities: ModelCapability | undefined,
): KimiConfig {
const providerName = 'test-provider';
const maxContextSize = modelCapabilities?.max_context_tokens;
return {
...config,
providers: {
...config.providers,
[providerName]: providerConfigForAlias(provider),
},
models: {
...config.models,
[provider.model]: {
provider: providerName,
model: provider.model,
maxContextSize:
maxContextSize === undefined || maxContextSize <= 0 ? 1_000_000 : maxContextSize,
capabilities: capabilityNames(modelCapabilities),
},
},
};
}
function providerConfigForAlias(provider: ProviderConfig): KimiConfig['providers'][string] {
return {
type: provider.type,
apiKey: 'apiKey' in provider ? provider.apiKey : undefined,
baseUrl: 'baseUrl' in provider ? provider.baseUrl : undefined,
};
}
function capabilityNames(capabilities: ModelCapability | undefined): string[] {
if (capabilities === undefined) return [];
return [
capabilities.image_in ? 'image_in' : undefined,
capabilities.video_in ? 'video_in' : undefined,
capabilities.audio_in ? 'audio_in' : undefined,
capabilities.thinking ? 'thinking' : undefined,
capabilities.tool_use ? 'tool_use' : undefined,
].filter((capability): capability is string => capability !== undefined);
}
function buildSkillPrompt(content: string, args: string | undefined): string {
if (args === undefined) return content;
return `${content}\n\nUser request:\n${args}`;
}
function cloneRecord(event: AgentRecord): AgentRecord {
return structuredClone(event);
}
function withMetadata(events: readonly AgentRecord[]): readonly AgentRecord[] {
if (events.length === 0 || events[0]?.type === 'metadata') return events;
return [
{
type: 'metadata',
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: 1,
},
...events,
];
}