mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* 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.
1119 lines
33 KiB
TypeScript
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,
|
|
];
|
|
}
|