From f79fde2b902947ddcfd0be36f766fbeae986ef07 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 28 Jul 2026 20:29:45 +0800 Subject: [PATCH] feat(node-sdk): migrate the SDK method surface to agent-core-v2 (#2262) * feat(node-sdk): add agent-core-v2 backed SDKRpcClientV2 harness - add SDKRpcClientV2 wiring the v2 engine (DI x Scope) in-process via the klient memory transport, with getExperimentalFeatures migrated to klient.global.flags.list() and unmigrated methods failing fast - export createKimiHarnessV2 / SDKRpcClientV2 from the SDK index - wire the experimental v2 gate into the CLI interactive shell (run-shell) - extend build-dts to bundle agent-core-v2 and klient declarations * feat(node-sdk): migrate listWorkspaceSkills to agent-core-v2 - add engineAccessor escape hatch exposing the in-process engine's app-scope service accessor for SDK methods the klient facade does not cover yet - implement listWorkspaceSkills via ISkillDiscovery plus the v2 user/project root helpers and BUILTIN_SKILLS (plugin skills and skillDirs still gaps) - add a v1-v2 parity test pinning identical return values per migrated method, with understood gaps listed explicitly in KNOWN_DIFFS * feat(node-sdk): migrate the SDK method surface to agent-core-v2 - implement the remaining SDKRpcClientBase methods on SDKRpcClientV2, routed through the klient facade where covered, the engineAccessor escape hatch where the engine has a service, or SDK-side rebuilds on v2 primitives where only primitives exist (config shape mapping, global mcp.json store, MCP OAuth flows, importContext, session warnings, print background policy) - translate the v2 event stream into the v1 Event union and bridge approval/question/user_tool interactions per live session - rebuild resume replay by folding the v2 wire.jsonl through the v1 agent restore pipeline, so resumed sessions render history again - keep deleteSession as not_implemented; the v2 engine has no delete capability - extend the v1-v2 parity suite to every migrated method, pinning understood engine differences in KNOWN_DIFFS - add the dev:cli:v2 root script to launch the TUI on the v2 engine * fix(node-sdk): await the v2 undo and compaction-cancel agent calls * test(cli): spread the real oauth module in the telemetry test mock * feat(node-sdk): forward v2 engine telemetry to the host telemetry client * feat(cli): gate the v2 TUI route behind a dedicated KIMI_CODE_TUI_V2 switch * fix(node-sdk): honor skillDirs on the agent-core-v2 SDK route The v2 SDK client accepted KimiHarnessOptions.skillDirs (the CLI's --skills-dir) but never seeded it into the engine, so explicit skill dirs were silently dropped on the v2 TUI route and the Skill tool could not find skills from them. Seed skillCatalogRuntimeOptions at bootstrap and let listWorkspaceSkills resolve the explicit dirs as the user source, matching the engine's session skill catalog. * refactor(cli): gate the v2 TUI route behind the master experimental flag again Drop the dedicated KIMI_CODE_TUI_V2 switch: the TUI v2 harness route is gated by KIMI_CODE_EXPERIMENTAL_FLAG, the same master switch as the kimi -p v2 route. The gate tests are kept with updated assertions, and dev:cli:v2 sets the master flag again. --- apps/kimi-code/src/cli/experimental-v2.ts | 15 +- apps/kimi-code/src/cli/run-shell.ts | 13 +- apps/kimi-code/test/cli/run-shell.test.ts | 96 +- apps/kimi-code/test/cli/telemetry.test.ts | 14 +- package.json | 1 + packages/node-sdk/package.json | 2 + packages/node-sdk/scripts/build-dts.mjs | 6 +- packages/node-sdk/src/index.ts | 5 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 2039 ++++++++ packages/node-sdk/src/v2/config-mapper.ts | 127 + packages/node-sdk/src/v2/event-mapper.ts | 89 + packages/node-sdk/src/v2/global-mcp.ts | 250 + packages/node-sdk/src/v2/import-context.ts | 110 + packages/node-sdk/src/v2/resume-replay.ts | 142 + packages/node-sdk/src/v2/session-mapper.ts | 96 + packages/node-sdk/src/v2/session-wiring.ts | 252 + .../node-sdk/test/sdk-rpc-client-v2.test.ts | 266 + packages/node-sdk/test/v1-v2-parity.test.ts | 4370 +++++++++++++++++ packages/node-sdk/tsconfig.dts.json | 2 + pnpm-lock.yaml | 8 +- 20 files changed, 7877 insertions(+), 26 deletions(-) create mode 100644 packages/node-sdk/src/sdk-rpc-client-v2.ts create mode 100644 packages/node-sdk/src/v2/config-mapper.ts create mode 100644 packages/node-sdk/src/v2/event-mapper.ts create mode 100644 packages/node-sdk/src/v2/global-mcp.ts create mode 100644 packages/node-sdk/src/v2/import-context.ts create mode 100644 packages/node-sdk/src/v2/resume-replay.ts create mode 100644 packages/node-sdk/src/v2/session-mapper.ts create mode 100644 packages/node-sdk/src/v2/session-wiring.ts create mode 100644 packages/node-sdk/test/sdk-rpc-client-v2.test.ts create mode 100644 packages/node-sdk/test/v1-v2-parity.test.ts diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index f3b2f1095..4f53508ba 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -1,14 +1,17 @@ /** - * Experimental agent-core-v2 engine gate for `kimi -p` (print mode). + * Experimental agent-core-v2 engine gate for the CLI surfaces. * - * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, print mode - * routes to the native agent-core-v2 runner instead of the default v1 - * harness (see `run-prompt.ts`). Read directly from the env (matching + * When the master switch `KIMI_CODE_EXPERIMENTAL_FLAG` is truthy, `kimi -p` + * (print mode) routes to the native agent-core-v2 runner (see + * `run-prompt.ts`) and the interactive TUI builds its harness through the + * SDK's v2-backed client (see `run-shell.ts`), both instead of the default + * v1 engine. The master switch also enables every experimental feature flag + * in the engine. Read directly from the env (matching * `cli/update/rollout.ts`) because the CLI must not depend on the core flag - * registry. Unset / any non-truthy value keeps the v1 harness. + * registry. Unset / any non-truthy value keeps the v1 path. * * Note: `kimi web` always boots kap-server (the agent-core-v2 engine - * server) — it no longer consults this switch. + * server) — it does not consult this switch. */ export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 7e0a8ce71..3497c4398 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -4,9 +4,11 @@ import { join } from 'node:path'; import { createKimiHarness, + createKimiHarnessV2, flushDiagnosticLogsSync, log, type KimiHarness, + type KimiHarnessOptions, type TelemetryClient, } from '@moonshot-ai/kimi-code-sdk'; import { @@ -29,6 +31,7 @@ import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; import type { CLIOptions } from './options'; +import { isKimiV2Enabled } from './experimental-v2'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createKimiCodeHostIdentity } from './version'; @@ -60,7 +63,7 @@ export async function runShell( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = createKimiHarness({ + const harnessOptions: KimiHarnessOptions = { homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, @@ -76,7 +79,13 @@ export async function runShell( }); }, sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, - }); + }; + // Experimental agent-core-v2 route (same master switch as `kimi -p`): the + // harness is the SDK's v2-backed client, so the whole TUI runs on the + // agent-core-v2 engine. + const harness = isKimiV2Enabled() + ? createKimiHarnessV2(harnessOptions) + : createKimiHarness(harnessOptions); log.info('kimi-code starting', { version, uiMode: CLI_UI_MODE, diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 2275d3a26..f835046ca 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => { loadTuiConfig: vi.fn(), detectTerminalTheme: vi.fn(), kimiHarnessConstructor: vi.fn(), + kimiHarnessV2Constructor: vi.fn(), harnessEnsureConfigFile: vi.fn(), harnessGetConfig: vi.fn(async () => ({ providers: {}, @@ -67,6 +68,21 @@ const mocks = vi.hoisted(() => { vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { const actual = await importOriginal(); + const makeHarnessStub = (args: unknown[]) => { + const options = args[0] as { readonly homeDir?: string } | undefined; + const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; + return { + homeDir, + auth: { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }, + ensureConfigFile: mocks.harnessEnsureConfigFile, + getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, + close: mocks.harnessClose, + track: mocks.harnessTrack, + }; + }; return { ...actual, resolveKimiHome: mocks.resolveKimiHome, @@ -78,17 +94,11 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { mocks.createKimiDeviceId(homeDir); } mocks.kimiHarnessConstructor(...args); - return { - homeDir, - auth: { - getCachedAccessToken: mocks.harnessGetCachedAccessToken, - }, - ensureConfigFile: mocks.harnessEnsureConfigFile, - getConfig: mocks.harnessGetConfig, - getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, - close: mocks.harnessClose, - track: mocks.harnessTrack, - }; + return makeHarnessStub(args); + }, + createKimiHarnessV2: (...args: unknown[]) => { + mocks.kimiHarnessV2Constructor(...args); + return makeHarnessStub(args); }, }; }); @@ -163,6 +173,70 @@ describe('runShell', () => { mocks.harnessCreatesDeviceIdOnConstruction = false; }); + const minimalCliOptions = { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }; + + function stubTuiStartup(): void { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + } + + function withEnv(patch: Record, fn: () => Promise): Promise { + const saved: Record = {}; + for (const key of Object.keys(patch)) { + saved[key] = process.env[key]; + const value = patch[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return fn().finally(() => { + for (const key of Object.keys(patch)) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + } + + it('builds the v2 harness when the master experimental flag is set', async () => { + stubTuiStartup(); + await withEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: '1' }, async () => { + await runShell(minimalCliOptions, '1.2.3-test'); + }); + expect(mocks.kimiHarnessV2Constructor).toHaveBeenCalledTimes(1); + expect(mocks.kimiHarnessConstructor).not.toHaveBeenCalled(); + }); + + it('keeps the v1 harness when the master experimental flag is unset', async () => { + stubTuiStartup(); + await withEnv({ KIMI_CODE_EXPERIMENTAL_FLAG: undefined }, async () => { + await runShell(minimalCliOptions, '1.2.3-test'); + }); + expect(mocks.kimiHarnessConstructor).toHaveBeenCalledTimes(1); + expect(mocks.kimiHarnessV2Constructor).not.toHaveBeenCalled(); + }); + it('constructs KimiHarness and KimiTUI with startup input', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/telemetry.test.ts b/apps/kimi-code/test/cli/telemetry.test.ts index a558b752f..490aa83ec 100644 --- a/apps/kimi-code/test/cli/telemetry.test.ts +++ b/apps/kimi-code/test/cli/telemetry.test.ts @@ -29,10 +29,16 @@ vi.mock('@moonshot-ai/kimi-telemetry', () => ({ withTelemetryContext: vi.fn(), })); -vi.mock('@moonshot-ai/kimi-code-oauth', () => ({ - createKimiDeviceId: mocks.createKimiDeviceId, - KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', -})); +vi.mock('@moonshot-ai/kimi-code-oauth', async (importOriginal) => { + // Spread the real module: the SDK's v2 client pulls agent-core-v2 into the + // import graph, which subclasses KimiOAuthToolkit from this package. + const actual = await importOriginal(); + return { + ...actual, + createKimiDeviceId: mocks.createKimiDeviceId, + KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', + }; +}); vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { const actual = await importOriginal(); diff --git a/package.json b/package.json index 6ae66cc11..e363af347 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "pnpm -r run build", "build:packages": "pnpm -r --filter './packages/*' run build", "dev:cli": "pnpm -C apps/kimi-code run dev", + "dev:cli:v2": "KIMI_CODE_EXPERIMENTAL_FLAG=1 pnpm -C apps/kimi-code run dev", "dev:cli:marketplace": "KIMI_CODE_DEV_MARKETPLACE_URL=https://code.kimi.com/kimi-code/plugins/marketplace.json pnpm -C apps/kimi-code run dev", "dev:web": "pnpm -C apps/kimi-web run dev", "dev:server": "pnpm -C apps/kimi-code run dev:server", diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index e77c592c7..31d086c65 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -60,8 +60,10 @@ }, "devDependencies": { "@moonshot-ai/agent-core": "workspace:^", + "@moonshot-ai/agent-core-v2": "workspace:^", "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", + "@moonshot-ai/klient": "workspace:^", "@moonshot-ai/kosong": "workspace:^", "@types/yazl": "^2.4.6", "jimp": "^1.6.1" diff --git a/packages/node-sdk/scripts/build-dts.mjs b/packages/node-sdk/scripts/build-dts.mjs index b25ac114b..383a044d6 100644 --- a/packages/node-sdk/scripts/build-dts.mjs +++ b/packages/node-sdk/scripts/build-dts.mjs @@ -12,11 +12,13 @@ const providerClientShimPath = path.join(dtsRoot, 'provider-clients.d.ts'); const tscBinPath = packageBinPath('typescript', 'bin/tsc'); const apiExtractorBinPath = packageBinPath('@microsoft/api-extractor', 'bin/api-extractor'); -const packageDirs = new Set(['agent-core', 'kaos', 'kosong', 'node-sdk', 'oauth']); +const packageDirs = new Set(['agent-core', 'agent-core-v2', 'kaos', 'klient', 'kosong', 'node-sdk', 'oauth']); const workspacePackages = new Map([ + ['@moonshot-ai/agent-core-v2', 'agent-core-v2'], ['@moonshot-ai/agent-core', 'agent-core'], ['@moonshot-ai/kaos', 'kaos'], ['@moonshot-ai/kimi-code-oauth', 'oauth'], + ['@moonshot-ai/klient', 'klient'], ['@moonshot-ai/kosong', 'kosong'], ]); @@ -105,7 +107,7 @@ async function rewriteWorkspaceSpecifiers() { `import { GoogleGenAI as GenAIClient } from '${providerClientSpecifier}';`, ); const updated = providerClientText.replaceAll( - /(["'])(#\/[^"']+|@moonshot-ai\/(?:agent-core|kaos|kimi-code-oauth|kosong)(?:\/[^"']+)?)\1/g, + /(["'])(#\/[^"']+|@moonshot-ai\/(?:agent-core-v2|agent-core|kaos|kimi-code-oauth|klient|kosong)(?:\/[^"']+)?)\1/g, (_match, quote, specifier) => { const resolved = resolveSpecifier({ currentFile: file, diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index cab37997d..8e42ae048 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -3,6 +3,11 @@ export type { KimiHarnessRuntimeOptions } from '#/kimi-harness'; export { Session } from '#/session'; export { KimiAuthFacade } from '#/auth'; export { createKimiHarness, SDKRpcClient, type SDKRpcClientOptions } from '#/sdk-rpc-client'; +export { + createKimiHarnessV2, + SDKRpcClientV2, + type SDKRpcClientV2Options, +} from '#/sdk-rpc-client-v2'; export { createKimiConfigRpc, KimiConfigRpcClient, diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts new file mode 100644 index 000000000..99b4cda7e --- /dev/null +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -0,0 +1,2039 @@ +/** + * v2 wiring MVP — an `SDKRpcClientBase` backed by the agent-core-v2 engine + * (DI × Scope) instead of the v1 `KimiCore` RPC pair. The engine is + * bootstrapped in-process and reached through the klient facade over the + * memory transport, so every call crosses the same contract validation and + * JSON round-trip as the networked transports. + * + * Migration model: the base class still carries the v1 method surface. Any + * method not yet overridden here falls through to `getRpc()`, which fails + * loudly with `not_implemented` — migrated methods are the ones overridden + * below. Once every method is migrated, the v1 `getRpc()` dependency (and + * the v1 core) goes away entirely. + * + * Migrated so far: + * - `getExperimentalFeatures` → `klient.global.flags.list()` + * - `listWorkspaceSkills` → not covered by the klient facade, so it goes + * through the `engineAccessor` escape hatch (`ISkillDiscovery` + the v2 + * skill-root helpers) instead. + * - `getConfig` / `setConfig` / `removeProvider` / `getConfigDiagnostics` → + * `klient.global.config.*`, with the v1 `KimiConfig` shape restored by the + * pure mapping layer in `src/v2/config-mapper.ts`. + * - `listPlugins` / `installPlugin` / `setPluginEnabled` / + * `setPluginMcpServerEnabled` / `removePlugin` / `reloadPlugins` / + * `getPluginInfo` / `listPluginCommands` → `klient.global.plugins.*`. The + * wire types are field-identical between the engines, so no mapping layer + * is needed. Unlike the config domain, the v2 plugin service serializes + * every read behind its own initial load, so there is no ready trap here. + * - `listSessions` / `createSession` / `renameSession` / `forkSession` / + * `closeSession` / `resumeSession` / `reloadSession` / + * `updateSessionMetadata` / `addAdditionalDir` → the session lifecycle + * batch: `klient.global.sessions.list` plus the `klient.session(id)` + * metadata mutations where the facade reaches, and the + * `ISessionLifecycleService` / session-scope services through + * {@link engineAccessor} where it does not (explicit session ids, resume, + * fork ids, workspace commands). The v1 `SessionSummary` / `SessionMeta` + * shapes are restored by the pure mapping layer in + * `src/v2/session-mapper.ts`. `deleteSession` stays `not_implemented` — + * the v2 engine has no session-deletion capability anywhere (tracked in + * `.tmp/v2-migration-tracker.md`). The resumed results carry the full v1 + * per-agent snapshot: the live slices are read from the restored agent + * scope (profile / permission / swarm services + the klient agent facade), + * while `replay` and `toolStore` are folded from each agent's `wire.jsonl` + * through the v1 engine's own restore pipeline + * (`src/v2/resume-replay.ts`) — `includeSubagents` and `replayTurnLimit` + * included. + * - `setModel` / `setPermission` / `setPlanMode` / `getPlan` / `clearPlan` / + * `getContext` / `getUsage` / `cancel` → the `klient.session(id).agent(id)` + * facade; `setThinking` / `compact` / `cancelCompaction` / `undoHistory` / + * `clearContext` / `importContext` → agent-scope services through the live + * session handle (no facade exists); `getStatus` → the same six-slice + * aggregate the base class builds, re-read from the profile / permission / + * swarm services plus the facade. `importContext` composes v1's exact + * message + rejections over v2 primitives (`src/v2/import-context.ts`) — + * the engine has no import capability of its own. `createSession`'s + * `model` / `thinking` / `permission` options are applied in this batch + * too (default-profile bind + permission mode). + * - `prompt` / `steer` / `runShellCommand` / `cancelShellCommand` → the + * `klient.session(id).agent(id)` facade; `activatePluginCommand` → + * `IAgentRPCService` through the agent scope; `activateSkill` → + * `IAgentSkillService` through the agent scope (the RPC service's + * fire-and-forget variant would swallow v1's synchronous rejections) plus + * v1's main-only metadata update; `generateAgentsMd` → + * `ISessionInitService` through the session scope; `getSessionWarnings` → + * rebuilt over the profile's cached AGENTS.md warning plus the engine's + * `prepareSystemPromptContext` (no v2 aggregate service exists). + * - `createGoal` / `getGoal` / `pauseGoal` / `resumeGoal` / `cancelGoal` → + * `IAgentGoalService` through the agent scope; `getCronTasks` → + * `ISessionCronService` through the session scope with the v1 snapshot + * shape restored; `listBackgroundTasks` / `getBackgroundTaskOutput` → the + * `klient.session(id).agent(id)` facade; `stopBackgroundTask` / + * `detachBackgroundTask` → `IAgentTaskService` through the agent scope + * (the facade's no-reason stop substitutes a user-cancellation reason v1 + * never records); `waitForBackgroundTasksOnPrint` / + * `handlePrintMainTurnCompleted` → rebuilt over the v2 print-mode config + * helpers and the session's per-agent task services (no v2 service owns + * the print policy). + * - `listGlobalMcpServers` / `addGlobalMcpServer` / `updateGlobalMcpServer` / + * `removeGlobalMcpServer` / `beginGlobalMcpServerAuth` / + * `completeGlobalMcpServerAuth` / `cancelGlobalMcpServerAuth` / + * `resetGlobalMcpServerAuth` / `testGlobalMcpServer` → the v1 user-global + * MCP surface, rebuilt in `src/v2/global-mcp.ts`: agent-core-v2 only reads + * the user-global `mcp.json` (nothing in the engine writes it) and binds + * its OAuth orchestrator inside the session scope, so the file store and + * the `require*` guards are byte-identical ports, driven by the v2 + * engine's own `McpOAuthService` / `McpConnectionManager` (deep imports — + * the package index does not re-export them) over the app-scope + * `IAtomicDocumentStore`, whose on-disk layout + * (`/credentials/mcp/-*.json`) matches v1's. + * - `listMcpServers` / `getMcpStartupMetrics` / `reconnectMcpServer` → + * `ISessionMcpService.connectionManager()` through the session scope (no + * klient facade exists); the v2 `McpServerEntry` is field-identical with + * v1's `McpServerInfo`. + * - `onEvent` / `receiveEvent` → the base class registries, fed by a + * per-live-session wiring (`src/v2/session-wiring.ts`) that subscribes + * every live agent's `IEventBus` and translates each `DomainEvent` back + * into the v1 `Event` shape (`src/v2/event-mapper.ts`); the klient events + * hub is deliberately bypassed because its contract registry exposes only + * 13 of the bus types (no `shell.*`, no `turn.step.*`, ...). The one + * v1-visible fact on the process-global `IEventService` + * (`session.meta.updated`) is forwarded from a constructor subscription. + * - `setApprovalHandler` / `setQuestionHandler` → the base class registries, + * driven by the same session wiring: v1's push callbacks + * (`requestApproval` / `requestQuestion` / `toolCall`) are fed from the v2 + * interaction kernel's pending set (`onDidChangePending`), and the outcome + * is written back through `ISessionApprovalService.decide` / + * `ISessionQuestionService.answer|dismiss` / the kernel's `respond`. + * - `exportSession` → `ISessionExportService` (app scope, the v2 port of v1's + * export) through {@link engineAccessor}; `listSkills` → the session + * scope's `ISessionSkillCatalog`; `startBtw` → the session scope's + * `ISessionBtwService`; `setSwarmMode` / `swarm` → the agent scope's + * `IAgentSwarmService` (the v2 port of v1's `SwarmMode`), with `swarm()` + * recomposed over the `setSwarmMode` + `prompt` overrides. + * `createSessionWithKaos` / `resumeSessionWithKaos` deliberately keep the + * base class's kaos-ignoring degradation (the v2 engine has no kaos + * injection point — see the session-lifecycle section header), and + * `toolCall` keeps the base class's "not supported" answer, which the + * interaction bridge already relies on. + */ +import { randomUUID } from 'node:crypto'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + ensureConfigFile, + ErrorCodes, + KimiError, + limitAgentReplayByTurns, + noopTelemetryClient, + type AgentContextData, + type BeginGlobalMcpServerAuthResult, + type ExperimentalFeatureState, +} from '@moonshot-ai/agent-core'; +import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; +import { MCP_SECTION, type McpSection } from '@moonshot-ai/agent-core-v2/agent/mcp/configSection'; +import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/agent/mcp/connection-manager'; +import { + AlreadyAuthorizedError, + McpOAuthService, + type BeginAuthorizationResult, +} from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/service'; +import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/agent/mcp/oauth/store'; +import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore'; +import { + applyPromptMetadataUpdate, + bootstrap, + BUILTIN_SKILLS, + DEFAULT_AGENT_PROFILE_NAME, + ensureKimiHome, + ensureMainAgent, + IAgentActivityView, + IAgentContextMemoryService, + IAgentContextSizeService, + IAgentFullCompactionService, + IAgentGoalService, + IAgentLifecycleService, + IAgentLoopService, + IAgentPermissionModeService, + IAgentPermissionRulesService, + IAgentProfileService, + configuredRoots, + IAgentRPCService, + IAgentSkillService, + IAgentSwarmService, + IAgentTaskService, + IBootstrapService, + IConfigService, + IEventService, + IHostEnvironment, + IHostFileSystem, + IModelService, + IProjectLocalConfigService, + IProviderService, + ISessionBtwService, + ISessionContext, + ISessionCronService, + ISessionExportService, + ISessionIndex, + ISessionInitService, + ISessionLifecycleService, + ISessionMcpService, + ISessionMetadata, + ISessionSkillCatalog, + ISessionWorkspaceCommandService, + ISessionWorkspaceContext, + ISkillCatalogRuntimeOptions, + ISkillDiscovery, + ITelemetryService, + IWorkspaceAliases, + logSeed, + MAIN_AGENT_ID, + prepareSystemPromptContext, + PRINT_MAX_TURNS_DEFAULT, + PRINT_WAIT_CEILING_S_DEFAULT, + ProfileError, + ProfileErrors, + projectRoots, + promptMetadataTextFromSkill, + resolveAgentTaskConfig, + resolveConfigPath, + resolveKimiHome, + resolveLoggingConfig, + resolvePrintBackgroundMode, + skillCatalogRuntimeOptionsSeed, + summarizeSkill, + userRoots, + type IAgentScopeHandle, + type IDisposable, + type ISessionScopeHandle, + type Scope, + type ServicesAccessor, +} from '@moonshot-ai/agent-core-v2'; +import type { AgentHandle, Klient } from '@moonshot-ai/klient'; +import { createKlient } from '@moonshot-ai/klient/memory'; +import { assertKimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; + +import { KimiAuthFacade } from '#/auth'; +import { KimiHarness } from '#/kimi-harness'; +import { + SDKRpcClientBase, + type ActivatePluginCommandRpcInput, + type ActivateSkillRpcInput, + type ImportContextRpcInput, + type ReconnectMcpServerRpcInput, + type ReloadSessionRpcInput, + type SessionIdRpcInput, + type SessionPromptRpcInput, + type SetSessionModelRpcInput, + type SetSessionModelRpcResult, + type SetSessionPermissionRpcInput, + type SetSessionPlanModeRpcInput, + type SetSessionSwarmModeRpcInput, + type SetSessionThinkingRpcInput, + type UpdateSessionMetadataRpcInput, +} from '#/rpc'; +import type { + AddAdditionalDirInput, + AddAdditionalDirResult, + BackgroundTaskInfo, + CompactOptions, + ConfigDiagnostics, + CreateGoalInput, + CreateSessionOptions, + ExportSessionInput, + ExportSessionResult, + ForkSessionInput, + GetConfigOptions, + GetCronTasksResult, + GoalSnapshot, + GoalToolResult, + JsonObject, + KimiConfig, + KimiConfigPatch, + KimiHarnessOptions, + KimiHostIdentity, + ListSessionsOptions, + McpServerConfig, + McpServerInfo, + McpStartupMetrics, + McpTestResult, + OAuthRefreshOutcome, + PluginCommandDef, + PluginInfo, + PluginSummary, + ReloadSummary, + RenameSessionInput, + ResumeSessionInput, + ResumedAgentState, + ResumedSessionSummary, + SessionPlan, + SessionStatus, + SessionSummary, + SessionUsage, + SkillSummary, + TelemetryClient, +} from '#/types'; +import { + diagnosticsToConfigDiagnostics, + planProviderRemoval, + resolvedConfigToKimiConfig, +} from '#/v2/config-mapper'; +import { translateGlobalEvent } from '#/v2/event-mapper'; +import { assertImportFits, buildImportContextMessage } from '#/v2/import-context'; +import { foldAgentWireReplay } from '#/v2/resume-replay'; +import { + GlobalMcpConfigStore, + mcpConfigWithoutName, + requireOAuthMcpServer, + requireRemoteMcpServer, + standaloneMcpTestResult, +} from '#/v2/global-mcp'; +import { + normalizeWorkDir, + v2MetaToSessionMeta, + v2SummaryToSessionSummary, +} from '#/v2/session-mapper'; +import { SessionEventWiring } from '#/v2/session-wiring'; + +export interface SDKRpcClientV2Options { + readonly homeDir?: string; + readonly configPath?: string; + readonly identity?: KimiHostIdentity; + /** + * Explicit skill directories for this process (v1's SDK `skillDirs` / + * the CLI's `--skills-dir`): when non-empty, default user / project skill + * discovery is skipped and these directories serve as the user skill + * source. Seeded into the engine's app-scope + * `ISkillCatalogRuntimeOptions`. + */ + readonly skillDirs?: readonly string[]; + readonly telemetry?: TelemetryClient; + readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; + readonly uiMode?: string; +} + +/** + * The largest `setTimeout` delay before Node's timer overflows into an + * immediate fire (2^31 - 1 ms ≈ 24.8 days) — the same bound v1's + * `timeoutOutcome` clamps to. + */ +const MAX_TIMER_DELAY_MS = 0x7fffffff; + +/** v1's default for `completeGlobalMcpServerAuth` when the host gives no timeout. */ +const DEFAULT_GLOBAL_MCP_AUTH_TIMEOUT_MS = 15 * 60 * 1000; + +export class SDKRpcClientV2 extends SDKRpcClientBase { + readonly homeDir: string; + readonly configPath: string; + readonly identity: KimiHostIdentity | undefined; + readonly telemetry: TelemetryClient; + readonly auth: KimiAuthFacade; + readonly klient: Klient; + + private readonly app: Scope; + /** + * The engine's config reads (`get`/`getAll`/`inspect`/`diagnostics`) are + * synchronous over state that only exists once the initial load settles; + * unlike the mutating methods they do not await `IConfigService.ready` + * internally, so every config override below awaits this first. Awaiting + * the engine's own ready handle (via the accessor) instead of issuing a + * dummy facade call keeps the reads honest no-ops. + */ + private readonly configReady: Promise; + /** + * Per-session print-steer state for `handlePrintMainTurnCompleted`: v1 + * keeps the deadline/turn counters on the `Session` object, so they reset + * when the session closes (a resume builds a fresh `Session`); mirrored + * here by deleting the entry in `closeSession` / `reloadSession`. + */ + private readonly printSteerStates = new Map(); + /** + * The model/provider registries (`IModelService` / `IProviderService`) + * share the config service's ready trap: their `get`/`list` reads are + * synchronous over state that only exists after hydration, and every + * agent-side model operation (profile bind, `setModel`, capability reads) + * flows through them. Agent-interaction overrides await this before + * touching a profile. + */ + private readonly modelReady: Promise; + /** + * The user-global MCP file store (`/mcp.json`) — the SDK-side port in + * `src/v2/global-mcp.ts`, because agent-core-v2 only reads that file and + * has no write-side service for it. + */ + private readonly globalMcpConfig: GlobalMcpConfigStore; + /** + * v1's per-core global OAuth orchestrator, mirrored per client. Built + * lazily over the app-scope `IAtomicDocumentStore` (same on-disk layout as + * v1's `/credentials/mcp/` file store). + */ + private globalMcpOAuth: McpOAuthService | undefined; + /** In-flight OAuth flows keyed by the flowId handed to the host (v1 shape). */ + private readonly globalMcpOAuthFlows = new Map(); + /** + * Per-live-session event/interaction wirings (`src/v2/session-wiring.ts`): + * created when a session materializes through this client (create / resume / + * fork / reload), dropped on close (ours or the engine's). Each wiring feeds + * the base class's event listeners from the session's per-agent event buses + * and bridges its pending approvals / questions / user-tool calls to the + * registered handlers. + */ + private readonly sessionWirings = new Map(); + /** App-scope subscriptions (global event forwarding, lifecycle tracking), disposed in {@link close}. */ + private readonly appSubscriptions: IDisposable[] = []; + + constructor(options: SDKRpcClientV2Options = {}) { + super(); + this.identity = + options.identity === undefined ? undefined : assertKimiHostIdentity(options.identity); + this.homeDir = resolveKimiHome(options.homeDir); + this.configPath = resolveConfigPath({ + homeDir: this.homeDir, + configPath: options.configPath, + }); + ensureKimiHome(this.homeDir); + this.telemetry = options.telemetry ?? noopTelemetryClient; + this.auth = new KimiAuthFacade({ + homeDir: this.homeDir, + configPath: this.configPath, + identity: this.identity, + onRefresh: options.onOAuthRefresh, + }); + + const { app } = bootstrap( + { + homeDir: this.homeDir, + configPath: this.configPath, + clientVersion: this.identity?.version, + }, + [ + ...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env })), + // `--skills-dir` (v1 parity): explicit skill dirs replace default + // user / project discovery for every session this client hosts. + ...skillCatalogRuntimeOptionsSeed(options.skillDirs), + ], + ); + this.app = app; + this.klient = createKlient({ scope: app }); + this.globalMcpConfig = new GlobalMcpConfigStore(this.homeDir); + this.configReady = app.accessor.get(IConfigService).ready; + this.installEngineTelemetry(options.telemetry); + this.modelReady = Promise.all([ + this.configReady, + app.accessor.get(IModelService).ready, + app.accessor.get(IProviderService).ready, + ]).then(() => undefined); + this.appSubscriptions.push( + // v1's stream carries `session.meta.updated` (the prompt metadata + // path) — the one v1-visible fact the v2 engine publishes on the + // process-global IEventService rather than a per-agent bus. Every other + // global-bus type is a daemon/WS-edge event the in-process v1 client + // never saw, so the translation filters down to that single type. + this.app.accessor.get(IEventService).subscribe((event) => { + const translated = translateGlobalEvent(event); + if (translated !== undefined) this.receiveEvent(translated); + }), + // A session closed without going through this client (archive, an + // engine-initiated close) drops its wiring with the scope. + this.app.accessor.get(ISessionLifecycleService).onDidCloseSession((closed) => { + this.unwireSession(closed.sessionId); + }), + ); + } + + async ensureConfigFile(): Promise { + await ensureConfigFile(this.configPath); + } + + async close(): Promise { + for (const wiring of this.sessionWirings.values()) { + wiring.dispose(); + } + this.sessionWirings.clear(); + for (const subscription of this.appSubscriptions) { + subscription.dispose(); + } + await this.klient.close(); + this.app.dispose(); + } + + /** + * Forward engine telemetry to the host-supplied client. Without this the + * client only served `KimiHarness`-level events and every engine-side event + * (`track2` facts from agent/session scopes) was dropped on the v2 route. + * The `ITelemetryAppender` shape is a structural superset of the v1 + * `TelemetryClient`, so the client installs directly. The `telemetry` + * config section gates engine events the same way the v2 print runner + * gates them; the host keeps owning the client's lifecycle (flush / + * shutdown stay with the host, matching the v1 core's arrangement). + */ + private installEngineTelemetry(client: TelemetryClient | undefined): void { + if (client === undefined) return; + const telemetry = this.app.accessor.get(ITelemetryService); + telemetry.setAppender(client); + void this.configReady.then(() => { + telemetry.setEnabled(this.engineAccessor.get(IConfigService).get('telemetry') !== false); + }); + } + + /** + * Escape hatch to the in-process engine's app-scope service accessor, for + * SDK methods whose capability exists in agent-core-v2 but is not (yet) + * exposed through the klient facade. This is a deliberate migration + * pressure valve, not a new public API direction: + * - it only exists because this client owns the bootstrapped `Scope` — + * there is nothing equivalent on a remote (ipc) transport, so anything + * built on it is in-process-only by construction; + * - it resolves App-scope services only. Session/agent services need their + * own scope handles (via the lifecycle services), not this accessor; + * - every use should name the klient facade method it stands in for, and + * move onto the facade once one exists. Remove when the migration ends. + */ + get engineAccessor(): ServicesAccessor { + return this.app.accessor; + } + + protected getRpc(): Promise { + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'This SDK method is not wired to agent-core-v2 yet.', + ); + } + + override async getExperimentalFeatures(): Promise { + return this.klient.global.flags.list(); + } + + /** + * klient has no skills facade; composed directly from the engine's + * app-scope `ISkillDiscovery` plus the v2 root helpers (user + project + * roots) and the code-defined `BUILTIN_SKILLS` via {@link engineAccessor}. + * `skillDirs` (explicit dirs) replaces the default user / project roots, + * matching the engine's session skill catalog. Gap vs the v1 + * implementation: plugin skills are not included. + */ + override async listWorkspaceSkills(workDir: string): Promise { + const bootstrapService = this.engineAccessor.get(IBootstrapService); + const discovery = this.engineAccessor.get(ISkillDiscovery); + const explicitDirs = this.engineAccessor.get(ISkillCatalogRuntimeOptions).explicitDirs ?? []; + const roots = + explicitDirs.length > 0 + ? await configuredRoots(explicitDirs, workDir, bootstrapService.osHomeDir, 'user') + : [ + ...(await userRoots(bootstrapService.homeDir, bootstrapService.osHomeDir)), + ...(await projectRoots(workDir)), + ]; + const { skills } = await discovery.discover(roots); + // Builtins are the lowest-priority contribution: a discovered skill with + // the same name shadows the builtin (v1 registry semantics). + const byName = new Map(); + for (const skill of [...BUILTIN_SKILLS, ...skills]) { + byName.set(skill.name, { + name: skill.name, + description: skill.description, + path: skill.path, + source: skill.source, + type: skill.metadata.type, + disableModelInvocation: skill.metadata.disableModelInvocation, + isSubSkill: skill.metadata.isSubSkill, + }); + } + return [...byName.values()]; + } + + /** + * v1 returns the whole config.toml document as one `KimiConfig`; v2 + * resolves the same file per config domain. `getAll()` is the effective + * view (file + env overlays + section defaults), which matches v1's + * runtime config (`loadRuntimeConfigSafe` + the KIMI_MODEL_* overlay); + * `reload` mirrors v1's re-read-from-disk option. + */ + override async getConfig(options?: GetConfigOptions): Promise { + await this.configReady; + if (options?.reload) { + await this.klient.global.config.reload(); + } + return resolvedConfigToKimiConfig(await this.klient.global.config.getAll()); + } + + override async getConfigDiagnostics(): Promise { + await this.configReady; + return diagnosticsToConfigDiagnostics(await this.klient.global.config.diagnostics()); + } + + /** + * A v1 patch is one deep-merge over the whole document; v2 deep-merges + * per domain with the same plain-object-recursive / array-replace + * semantics, so the patch fans out one `config.set` per top-level field. + * Unknown-to-v2 fields (`yolo`, `planMode`, `telemetry`, ...) persist as + * unregistered pass-through domains, like v1's schema keeping them. + */ + override async setConfig(patch: KimiConfigPatch): Promise { + await this.configReady; + for (const [domain, domainPatch] of Object.entries(patch)) { + if (domainPatch === undefined) continue; + await this.klient.global.config.set({ domain, patch: domainPatch }); + } + return this.getConfig(); + } + + /** + * v1's removal cascades: the provider entry, every model pointing at it, + * and the default pointers when they dangle. The engine's own + * `kosong.removeProvider` only clears the default-provider pointer, so + * the full v1 cascade is computed from the user-layer values and applied + * through the config facade (see `planProviderRemoval`). + */ + override async removeProvider(providerId: string): Promise { + await this.configReady; + const [providers, models, defaultModel, defaultProvider] = await Promise.all([ + this.klient.global.config.inspect>('providers'), + this.klient.global.config.inspect>>('models'), + this.klient.global.config.inspect('defaultModel'), + this.klient.global.config.inspect('defaultProvider'), + ]); + const plan = planProviderRemoval({ + providers: providers.userValue, + models: models.userValue, + defaultModel: defaultModel.userValue, + defaultProvider: defaultProvider.userValue, + providerId, + }); + await this.klient.global.config.replace({ domain: 'providers', value: plan.providers }); + await this.klient.global.config.replace({ domain: 'models', value: plan.models }); + if (plan.clearDefaultModel) { + await this.klient.global.config.replace({ domain: 'defaultModel', value: undefined }); + } + if (plan.clearDefaultProvider) { + await this.klient.global.config.replace({ domain: 'defaultProvider', value: undefined }); + } + return this.getConfig(); + } + + override async listPlugins(): Promise { + return this.klient.global.plugins.list(); + } + + override async installPlugin(source: string): Promise { + return this.klient.global.plugins.install(source); + } + + override async setPluginEnabled(id: string, enabled: boolean): Promise { + return this.klient.global.plugins.setEnabled({ id, enabled }); + } + + override async setPluginMcpServerEnabled( + id: string, + server: string, + enabled: boolean, + ): Promise { + return this.klient.global.plugins.setMcpServerEnabled({ id, server, enabled }); + } + + override async removePlugin(id: string): Promise { + return this.klient.global.plugins.remove(id); + } + + override async reloadPlugins(): Promise { + return this.klient.global.plugins.reload(); + } + + override async getPluginInfo(id: string): Promise { + return this.klient.global.plugins.info(id); + } + + /** + * Scope gap: v1 answers from the session's creation-time snapshot of the + * enabled plugin commands, while the v2 engine only exposes the app-global + * live view (`pluginService.listPluginCommands`), so the sessionId is + * ignored here. The two agree for any session created after the last + * plugin change; a v1 session predating an install/toggle goes stale where + * v2 stays live. + */ + override async listPluginCommands( + input: SessionIdRpcInput, + ): Promise { + void input; + return this.klient.global.plugins.listCommands(); + } + + // ----------------------------------------------------------------------- + // Session lifecycle + // + // The v2 engine splits what v1's SessionStore + in-memory session map did + // across the app-scope `ISessionIndex` (persisted read model), + // `ISessionLifecycleService` (live session scopes), and the session-scope + // metadata/workspace services. The klient facade covers listing and the + // metadata mutations of a LIVE session; everything that needs an explicit + // session id, a resume, or a workspace command goes through the + // `engineAccessor` escape hatch (named per method below). + // + // `createSessionWithKaos` / `resumeSessionWithKaos` are deliberately NOT + // overridden: agent-core-v2 has no kaos injection point (its fs/process + // abstraction is the engine-internal hostFs domain, resolved at bootstrap), + // so the base class's degradation — ignore the kaos arguments and run a + // plain local create/resume — is the honest behavior, the same one every + // daemon-transport client settles for. Failing loudly instead would break + // hosts that pass kaos opportunistically (the harness forwards it whenever + // the host supplies one). + // ----------------------------------------------------------------------- + + private get sessionLifecycle(): ISessionLifecycleService { + return this.engineAccessor.get(ISessionLifecycleService); + } + + /** v1's `requireSession` / store lookup failure shape. */ + private static sessionNotFound(sessionId: string): KimiError { + return new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { + details: { sessionId }, + }); + } + + /** The live session handle, or the error v1 raises for a non-active session. */ + private requireLiveSession(sessionId: string): ISessionScopeHandle { + const handle = this.sessionLifecycle.get(sessionId); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); + return handle; + } + + /** + * Attach the event/interaction wiring to a freshly materialized session + * (idempotent). Unwiring needs no call site of its own: every close path + * goes through the engine's lifecycle close, whose `onDidCloseSession` + * subscription (constructor) drops the wiring. + */ + private wireSession(handle: ISessionScopeHandle): void { + if (this.sessionWirings.has(handle.id)) return; + this.sessionWirings.set(handle.id, new SessionEventWiring(handle, this)); + } + + private unwireSession(sessionId: string): void { + const wiring = this.sessionWirings.get(sessionId); + if (wiring === undefined) return; + this.sessionWirings.delete(sessionId); + wiring.dispose(); + } + + /** + * The v1 summary of a live session, read from its own scope services (the + * metadata document, the context's cwd/sessionDir, the workspace context's + * additional dirs) rather than the index — no disk round-trip, and the + * additional dirs only exist on the live session in both engines. + */ + private async liveSessionSummary(handle: ISessionScopeHandle): Promise { + const meta = await handle.accessor.get(ISessionMetadata).read(); + const ctx = handle.accessor.get(ISessionContext); + const workspace = handle.accessor.get(ISessionWorkspaceContext); + return { + id: meta.id, + title: meta.title, + lastPrompt: meta.lastPrompt, + workDir: ctx.cwd, + sessionDir: ctx.sessionDir, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + archived: meta.archived, + metadata: meta.custom as JsonObject | undefined, + additionalDirs: workspace.additionalDirs, + }; + } + + /** + * The `ResumedSessionSummary` of a just-materialized session, including the + * per-agent snapshot v1 serves: the live slices are read from the restored + * agent scope (profile / permission / swarm services and the klient agent + * facade for context / plan / usage / background tasks), while `replay` and + * `toolStore` are folded from the agent's `wire.jsonl` by + * {@link foldAgentWireReplay} (v2 has no replay builder of its own). + * `warning` stays undefined — v2's resume has no migration-warning channel. + */ + private async resumedSessionSummary( + handle: ISessionScopeHandle, + replay?: { readonly includeSubagents?: boolean; readonly replayTurnLimit?: number }, + ): Promise { + const meta = await handle.accessor.get(ISessionMetadata).read(); + const agents: Record = {}; + // v1 resumes the main agent eagerly; materializing here cold-restores its + // wire into the scope (create-or-get) and applies the default binding. + const main = await this.materializeMainAgent(handle); + agents[MAIN_AGENT_ID] = await this.resumedAgentState( + handle, + main, + 'main', + replay?.replayTurnLimit, + ); + if (replay?.includeSubagents === true) { + const agentsDir = join(handle.accessor.get(ISessionContext).sessionDir, 'agents'); + let subagentIds: readonly string[] = []; + try { + subagentIds = (await readdir(agentsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== MAIN_AGENT_ID) + .map((entry) => entry.name); + } catch { + // No agents directory at all → the main agent is the whole roster. + } + for (const agentId of subagentIds) { + try { + // `create` is create-or-get and cold-restores the persisted wire. + const agent = await handle.accessor.get(IAgentLifecycleService).create({ agentId }); + agents[agentId] = await this.resumedAgentState( + handle, + agent, + 'sub', + replay.replayTurnLimit, + ); + } catch { + // Best-effort, same as v1: a subagent whose restore fails is left + // out of the map (v1 logs a warning and continues with the rest). + } + } + } + return { + ...(await this.liveSessionSummary(handle)), + sessionMetadata: v2MetaToSessionMeta(meta), + agents, + warning: undefined, + }; + } + + /** + * One agent's v1 `ResumedAgentState`. The scope reads mirror v1's + * `resumeSessionResult` field-by-field; the casts only bridge the two + * packages' type declarations (the wire shapes are the documented-identical + * ports, same as the `getContext` / `listBackgroundTasks` overrides). One + * deliberate gap: `config.provider` is always undefined — v1 resolves the + * full runtime `ProviderConfig` into the snapshot, agent-core-v2 has no + * equivalent read, and the TUI only falls back to `provider?.model` when + * `modelAlias` is unset (pinned in the parity KNOWN_DIFFS). + */ + private async resumedAgentState( + session: ISessionScopeHandle, + agent: IAgentScopeHandle, + type: 'main' | 'sub', + replayTurnLimit?: number, + ): Promise { + const facade = this.klient.session(session.id).agent(agent.id); + const ctx = session.accessor.get(ISessionContext); + const [context, plan, usage, background, folded] = await Promise.all([ + facade.getContext(), + facade.getPlan(), + facade.getUsage(), + facade.getTasks({ activeOnly: false }), + foldAgentWireReplay(join(ctx.sessionDir, 'agents', agent.id, 'wire.jsonl')), + ]); + const profile = agent.accessor.get(IAgentProfileService).data(); + return { + type, + config: { + cwd: ctx.cwd, + provider: undefined, + modelAlias: profile.modelAlias, + modelCapabilities: profile.modelCapabilities, + profileName: profile.profileName, + thinkingEffort: profile.thinkingLevel, + systemPrompt: profile.systemPrompt, + }, + context: context as AgentContextData, + replay: limitAgentReplayByTurns(folded.replay, replayTurnLimit), + permission: { + mode: agent.accessor.get(IAgentPermissionModeService).mode, + rules: [...agent.accessor.get(IAgentPermissionRulesService).rules], + } as ResumedAgentState['permission'], + plan: plan as ResumedAgentState['plan'], + swarmMode: agent.accessor.get(IAgentSwarmService).isActive, + usage: usage as ResumedAgentState['usage'], + tools: agent.accessor.get(IAgentRPCService).getTools({}) as ResumedAgentState['tools'], + toolStore: folded.toolStore, + background: background as readonly BackgroundTaskInfo[], + }; + } + + /** + * Every v2 workspace-id bucket addressing `workDir` (already normalized): + * the registered workspace's alias set when the catalog knows the root, or + * the freshly minted bucket key for index-only sessions (mirrors how v1's + * store lists a bucket that never touched the workspace registry). + */ + private async workspaceIdsFor(workDir: string): Promise { + const workspaces = await this.klient.global.workspaces.list(); + const match = workspaces.find((workspace) => normalizeWorkDir(workspace.root) === workDir); + if (match === undefined) return [encodeWorkDirKey(workDir)]; + return this.engineAccessor.get(IWorkspaceAliases).resolveAliasIds(match.id); + } + + override async listSessions(input: ListSessionsOptions = {}): Promise { + // v1 rejects an empty workDir and bucket-filters by the normalized path; + // the v2 index filters by workspace-id set instead. + const workspaceIds = + input.workDir === undefined + ? undefined + : await this.workspaceIdsFor(normalizeRequiredWorkDir('listSessions', input.workDir)); + const page = await this.klient.global.sessions.list({ + workspaceIds, + sessionId: input.sessionId, + }); + const bootstrapService = this.engineAccessor.get(IBootstrapService); + const workspacesById = new Map( + (await this.klient.global.workspaces.list()).map((workspace) => [workspace.id, workspace]), + ); + const summaries: SessionSummary[] = []; + for (const item of page.items) { + const workDir = item.cwd ?? workspacesById.get(item.workspaceId)?.root; + // A session whose workDir is unrecoverable (corrupt metadata, deleted + // workspace) cannot be resumed on either engine; v1's store never lists + // one in the first place, so drop it here too. + if (workDir === undefined) continue; + summaries.push( + v2SummaryToSessionSummary(item, { + workDir, + sessionDir: bootstrapService.sessionDir(item.workspaceId, item.id), + }), + ); + } + return summaries; + } + + /** + * v1 semantics: register the workDir as a workspace and create the session + * (the v2 `sessionLifecycleService.create` does both; the klient facade + * wrapper is bypassed because it takes neither an explicit session id nor + * caller metadata). The `model` / `thinking` / `permission` options are the + * main-agent configuration v1 applies eagerly at creation: supplying any of + * them materializes the main agent here (v2 otherwise keeps it lazy) and + * binds the default profile with the requested model/thinking. v1 never + * validates either at create time — an unknown alias is recorded verbatim + * and an unlisted effort normalizes to the model default — so the bind is + * deliberately NOT `strictThinking`, and the v2-only create-time rejections + * that still leak through (unknown alias → `config.invalid`, no configured + * default model → `model.not_configured`) are pinned in the parity tests. + */ + override async createSession(input: CreateSessionOptions): Promise { + const workDir = normalizeRequiredWorkDir('createSession', input.workDir); + if (input.id !== undefined) { + const existing = + this.sessionLifecycle.get(input.id) ?? + (await this.engineAccessor.get(ISessionIndex).get(input.id)); + if (existing !== undefined) { + throw new KimiError( + ErrorCodes.SESSION_ALREADY_EXISTS, + `Session "${input.id}" already exists`, + ); + } + } + const handle = await this.sessionLifecycle.create({ + sessionId: input.id, + workDir, + additionalDirs: input.additionalDirs, + }); + // Wired before the optional main-agent materialization so a profile-bind + // warning (oversized AGENTS.md) reaches the listeners like v1's create. + this.wireSession(handle); + if ( + input.model !== undefined || + input.thinking !== undefined || + input.permission !== undefined + ) { + const agent = await this.materializeMainAgent(handle, { + model: input.model, + thinking: input.thinking, + }); + if (input.permission !== undefined) { + agent.accessor.get(IAgentPermissionModeService).setMode(input.permission); + } + } + if (input.metadata !== undefined) { + await this.klient.session(handle.id).update({ custom: { ...input.metadata } }); + } + // v1 returns the caller's metadata verbatim on create (not the merged + // custom map a later listing would report), so override it here too. + return { ...(await this.liveSessionSummary(handle)), metadata: input.metadata }; + } + + /** + * v1 renames through the live session when there is one and at the store + * level otherwise. The v2 metadata service is session-scoped (and the + * klient session facade 404s on a non-live session), so a closed session is + * resumed, renamed, and closed again to land in the same state. The v2 + * `setTitle` does no validation, so v1's trim + empty-title rejection lives + * here. + */ + override async renameSession(input: RenameSessionInput): Promise { + const title = input.title.trim(); + if (title.length === 0) { + throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); + } + if (this.sessionLifecycle.get(input.id) !== undefined) { + await this.klient.session(input.id).setTitle(title); + return; + } + const handle = await this.sessionLifecycle.resume(input.id); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + try { + await this.klient.session(input.id).setTitle(title); + } finally { + await this.sessionLifecycle.close(input.id); + } + } + + /** + * Through `engineAccessor` (`ISessionLifecycleService.fork`) because the + * klient facade fork takes no explicit target id. Known gaps vs v1: the + * engine's fork is unconditional — it never rejects an in-flight source + * turn (v1's SESSION_FORK_ACTIVE_TURN) — and `turnIndex` truncation has no + * v2 counterpart at all, so it fails loudly. The default title also differs + * by design (v1: "New Session", v2: "Fork: ") — pass an explicit + * title for identical results. + */ + override async forkSession(input: ForkSessionInput): Promise { + if (input.turnIndex !== undefined) { + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'forkSession turnIndex truncation is not wired to agent-core-v2 yet.', + ); + } + const handle = await this.sessionLifecycle.fork({ + sourceSessionId: input.id, + newSessionId: input.forkId, + title: input.title, + metadata: input.metadata, + }); + this.wireSession(handle); + return this.resumedSessionSummary(handle); + } + + override async closeSession(input: SessionIdRpcInput): Promise { + // v1's print-steer counters die with the Session object; drop ours too. + this.printSteerStates.delete(input.sessionId); + await this.klient.session(input.sessionId).close(); + } + + /** + * Materializes the session through `engineAccessor` + * (`ISessionLifecycleService.resume`; the facade only offers `restore`, + * which would also clear the archived flag — v1's resume does not). + * `includeSubagents` / `replayTurnLimit` shape the returned per-agent + * snapshot exactly like v1: subagent states are folded best-effort from + * each persisted agent wire, and every agent's replay is trimmed to the + * most recent N user turns via the shared `limitAgentReplayByTurns`. + */ + override async resumeSession(input: ResumeSessionInput): Promise { + const handle = await this.sessionLifecycle.resume(input.id); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + this.wireSession(handle); + // v1 re-resolves caller-provided additional dirs on every resume and + // merges them over the workspace-local set; the engine's own resume only + // restores the workspace-local ones. + if (input.additionalDirs !== undefined && input.additionalDirs.length > 0) { + const workspace = handle.accessor.get(ISessionWorkspaceContext); + const resolved = await this.engineAccessor + .get(IProjectLocalConfigService) + .resolveAdditionalDirs(workspace.workDir, input.additionalDirs); + workspace.setAdditionalDirs([...workspace.additionalDirs, ...resolved]); + } + return this.resumedSessionSummary(handle, { + includeSubagents: input.includeSubagents, + replayTurnLimit: input.replayTurnLimit, + }); + } + + /** + * v1's reload: refuse while a turn runs, re-read config + plugins, close + * the live session, resume from disk. The v2 busy check reads each live + * agent's activity view (turn lane only — background tasks do not block, + * matching v1's `hasActiveTurn`). `forcePluginSessionStartReminder` has no + * v2 channel (the engine owns plugin session-start injection) and is + * ignored. + */ + override async reloadSession(input: ReloadSessionRpcInput): Promise { + const sessionId = input.sessionId; + const live = this.sessionLifecycle.get(sessionId); + if (live !== undefined) { + for (const agent of live.accessor.get(IAgentLifecycleService).list()) { + if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { + throw new KimiError( + ErrorCodes.TURN_AGENT_BUSY, + `Session "${sessionId}" cannot be reloaded while a turn is running`, + { details: { sessionId } }, + ); + } + } + } else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) { + throw SDKRpcClientV2.sessionNotFound(sessionId); + } + await this.configReady; + await this.klient.global.config.reload(); + await this.klient.global.plugins.reload(); + if (live !== undefined) { + await this.sessionLifecycle.close(sessionId); + } + // Same print-steer reset as closeSession: v1's reload rebuilds the + // Session, and with it the counters. + this.printSteerStates.delete(sessionId); + const handle = await this.sessionLifecycle.resume(sessionId); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); + this.wireSession(handle); + return this.resumedSessionSummary(handle); + } + + /** + * The base-class contract merges the patch into the session's `custom` map + * (v1 routes through the live session and 404s on a closed one; mirrored + * here by {@link requireLiveSession}). + */ + override async updateSessionMetadata(input: UpdateSessionMetadataRpcInput): Promise { + this.requireLiveSession(input.sessionId); + const current = await this.klient.session(input.sessionId).get(); + const custom = { ...current.custom, ...input.metadata }; + await this.klient.session(input.sessionId).update({ custom }); + } + + /** + * Through `engineAccessor` (`ISessionWorkspaceCommandService`, session + * scope) — no klient facade exists for workspace commands. The v2 service + * returns the same `{additionalDirs, projectRoot, configPath, persisted}` + * shape as v1. Gap: with `persist: false` v1 also writes the dir into the + * session metadata so it survives a resume; v2 keeps it in memory only, so + * a non-persisted dir is lost on resume. + */ + override async addAdditionalDir(input: AddAdditionalDirInput): Promise { + const handle = this.requireLiveSession(input.id); + return handle.accessor + .get(ISessionWorkspaceCommandService) + .addAdditionalDir({ path: input.path, persist: input.persist }); + } + + /** + * Through `engineAccessor` (`ISessionExportService`, app scope) — the v2 + * port of v1's export: same payload fields, same zip writer layout, same + * live-session flush before the read, and the same + * `SESSION_EXPORT_NOT_FOUND` for a session without an exportable directory. + * Works on closed sessions on both engines (v1 reads the store, v2 the + * index). Gaps, pinned in the migration tracker: v2 additionally validates + * the host `version` (`SESSION_EXPORT_MISSING_VERSION` on blank — v1 + * records it unchecked), the manifest's activity timestamps come from v2's + * per-agent wire scan (v1 scans only the root `wire.jsonl`), and the + * manifest carries v2's extra `webLogPath` field (absent unless the host + * passes a web log, which this client never does). The zip ENTRY LIST is + * not part of the parity surface: the two engines lay their session + * directories out differently by design. + */ + override async exportSession(input: ExportSessionInput): Promise { + return this.engineAccessor.get(ISessionExportService).export({ + sessionId: input.id, + outputPath: input.outputPath, + includeGlobalLog: input.includeGlobalLog, + version: input.version, + installSource: input.installSource, + shellEnv: input.shellEnv, + }); + } + + /** + * Through the session scope (`ISessionSkillCatalog`) — no klient facade + * exists. Same merged view v1's `Session.listSkills` serves (builtin + + * user + project + plugin skills through the same `summarizeSkill` + * mapping), with the same snapshot-vs-live caveat as `listPluginCommands`: + * v1 loads the registry once at session creation while the v2 catalog + * re-merges on source changes mid-session; the two agree for any session + * created after the last skill change. + */ + override async listSkills(input: SessionIdRpcInput): Promise { + const catalog = this.requireLiveSession(input.sessionId).accessor.get(ISessionSkillCatalog); + await catalog.ready; + return catalog.catalog.listSkills().map(summarizeSkill); + } + + // ----------------------------------------------------------------------- + // Agent interaction + // + // v1 serves these from the session's eagerly-created main agent, already + // configured with the model/thinking defaults. The v2 engine splits them + // across the klient agent facade (model / permission / plan / context / + // usage / cancel — validated contract calls) and agent-scope services the + // facade does not cover (thinking, compaction, undo, context mutation), + // reached through the live session handle. Every override requires a live + // session (v1's `requireSession`) and resolves the target agent from + // `interactiveAgentId`: the main agent materializes on first use with the + // default profile bound (v1's eager equivalent); any other agent must + // already exist (v1's `AGENT_NOT_FOUND`). + // ----------------------------------------------------------------------- + + /** + * The session's materialized main agent with v1's eager default binding + * applied: a freshly created agent whose profile is still unbound gets the + * default profile + configured default model (the same bind kap-server's + * prompt route performs on first use). A home with no configured model + * leaves the agent unbound instead of failing — v1's model-less session + * reads (`model: undefined`, `'off'` thinking, zero capabilities) map onto + * the unbound state exactly. + */ + private async materializeMainAgent( + session: ISessionScopeHandle, + binding?: { readonly model?: string; readonly thinking?: string }, + ): Promise { + await this.modelReady; + const agent = await ensureMainAgent(session); + const profile = agent.accessor.get(IAgentProfileService); + if (binding !== undefined || profile.data().profileName === undefined) { + try { + await profile.bind({ + profile: DEFAULT_AGENT_PROFILE_NAME, + model: binding?.model, + thinking: binding?.thinking, + }); + } catch (error) { + if ( + binding === undefined && + error instanceof ProfileError && + error.code === ProfileErrors.codes.MODEL_NOT_CONFIGURED + ) { + return agent; + } + throw error; + } + } + return agent; + } + + /** The target agent's live scope handle (see the section header). */ + private async agentScope(sessionId: string): Promise { + const session = this.requireLiveSession(sessionId); + const agentId = this.interactiveAgentId; + if (agentId === MAIN_AGENT_ID) return this.materializeMainAgent(session); + const agent = session.accessor.get(IAgentLifecycleService).get(agentId); + if (agent === undefined) { + throw new KimiError(ErrorCodes.AGENT_NOT_FOUND, `Agent "${agentId}" was not found`); + } + return agent; + } + + /** + * The klient agent facade for the target agent. The scope is resolved + * first so the main agent exists and carries its default binding before + * the facade call crosses the channel (the channel's own materialization + * leaves the profile unbound). + */ + private async agentFacade(sessionId: string): Promise { + await this.agentScope(sessionId); + return this.klient.session(sessionId).agent(this.interactiveAgentId); + } + + /** + * Facade (`agentProfileService.setModel`). Both engines resolve the alias + * up front, report the resolved provider name, and reject an unknown alias + * with `config.invalid` (only the trailing message wording differs). + */ + override async setModel(input: SetSessionModelRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.setModel(input.model); + } + + /** + * Through the agent scope (`IAgentProfileService.setThinking`) — no klient + * facade exists. Same registry-driven strictness as v1's + * `setThinkingEffort`: an unlisted effort on a strict-thinking model + * rejects with `model.config_invalid` and the same message on both + * engines; anything else normalizes through the same resolution. + */ + override async setThinking(input: SetSessionThinkingRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + agent.accessor.get(IAgentProfileService).setThinking(input.effort); + } + + override async setPermission(input: SetSessionPermissionRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.setPermission(input.mode); + } + + /** v1 maps the toggle onto two RPCs (`enterPlan` / `cancelPlan`); so does v2. */ + override async setPlanMode(input: SetSessionPlanModeRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + if (!input.enabled) return agent.cancelPlan(); + return agent.enterPlan(); + } + + override async getPlan(input: SessionIdRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.getPlan(); + } + + override async clearPlan(input: SessionIdRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.clearPlan(); + } + + /** + * Facade (`agentRPCService.getContext`). The v2 `AgentContextData` is the + * same wire shape as v1's — the cast only bridges the two packages' type + * declarations (v2's origin union carries kinds a v1 client never sees in + * practice); the data itself crossed the same JSON boundary on both sides. + * Token-count semantics differ by design: v1 reports the running estimate, + * v2 the provider-measured prefix (`0` until the first LLM round) — pinned + * in the parity KNOWN_DIFFS. + */ + override async getContext(input: SessionIdRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.getContext() as Promise; + } + + override async getUsage(input: SessionIdRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.getUsage(); + } + + /** + * The base class aggregates v1's per-agent `getConfig` / `getContext` / + * `getPermission` / `getPlan` / `getSwarmMode` / `getUsage` RPCs. The v2 + * rebuild reads the same six slices: the profile's bound model alias and + * resolved thinking level + capabilities (v1's agent `getConfig` — its + * `provider?.model` fallback is unreachable without an alias), the + * facade's context/plan/usage, and the permission-mode and swarm services. + */ + override async getStatus(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + const facade = this.klient.session(input.sessionId).agent(this.interactiveAgentId); + const [context, plan, usage] = await Promise.all([ + facade.getContext(), + facade.getPlan(), + facade.getUsage(), + ]); + const profile = agent.accessor.get(IAgentProfileService).data(); + const capability = profile.modelCapabilities; + const maxContextTokens = capability.max_input_tokens ?? capability.max_context_tokens; + const contextTokens = context.tokenCount; + // Deliberately unclamped, same as the base class (>100% is the documented + // overflow signal on this path). + const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + const hasUsage = + usage.byModel !== undefined || usage.total !== undefined || usage.currentTurn !== undefined; + return { + model: profile.modelAlias, + thinkingEffort: profile.thinkingLevel, + permission: agent.accessor.get(IAgentPermissionModeService).mode, + planMode: plan !== null, + swarmMode: agent.accessor.get(IAgentSwarmService).isActive, + contextTokens, + maxContextTokens, + contextUsage, + usage: hasUsage ? usage : undefined, + }; + } + + override async cancel(input: SessionIdRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.cancel(); + } + + /** + * Through the agent scope (`IAgentFullCompactionService.begin`) — no klient + * facade exists. Same semantics as v1's `beginCompaction`: a manual + * compaction launches the summarizer immediately in the background, is a + * silent no-op while one is already running, and rejects with + * `compaction.unable` on an empty history or an active turn. + */ + override async compact(input: SessionIdRpcInput & CompactOptions): Promise { + const agent = await this.agentScope(input.sessionId); + agent.accessor.get(IAgentFullCompactionService).begin({ + source: 'manual', + instruction: input.instruction, + }); + } + + /** + * Through the agent scope (`IAgentRPCService.cancelCompaction`, the v2 RPC + * surface's own cancel) — no klient facade exists. Aborts the in-flight + * compaction; a no-op when idle, like v1. + */ + override async cancelCompaction(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + await agent.accessor.get(IAgentRPCService).cancelCompaction({}); + } + + /** + * Through the agent scope (`IAgentRPCService.undoHistory`, the v2 RPC + * surface's own undo) — no klient facade exists; the returned count is + * dropped (v1 returns void). Failure semantics differ by design: v2 + * prechecks and rejects atomically with `session.undo_unavailable`, while + * v1 splices a partial suffix out of the live history and then throws + * `request.invalid` — pinned in the parity KNOWN_DIFFS. + */ + override async undoHistory(input: SessionIdRpcInput & { count: number }): Promise { + const agent = await this.agentScope(input.sessionId); + await agent.accessor.get(IAgentRPCService).undoHistory({ count: input.count }); + } + + /** + * Through the agent scope (`IAgentContextMemoryService.clear`) — no klient + * facade exists. v1's `context.clear` has no busy check and does not touch + * queued or running prompts; the memory-service clear matches that exactly + * (the prompt service's own `clear` would additionally abort prompts). + */ + override async clearContext(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + agent.accessor.get(IAgentContextMemoryService).clear(); + } + + /** + * No v2 engine capability exists for import-context (nothing under + * agent-core-v2 builds this message), so the SDK composes v1's exact + * behavior over v2 primitives: the same busy rejection + * (`turn.agent_busy`), the byte-identical import message and validations + * (`src/v2/import-context.ts`), the same overflow gate, then the same wire + * `context.append_message` Op v1 persists. Known gap: v1 also adopts the + * post-import estimate as its reported token count, while v2's reported + * count is provider-measured and has no public setter — post-import + * `getContext().tokenCount` diverges (pinned in the parity KNOWN_DIFFS). + */ + override async importContext(input: ImportContextRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + if ( + agent.accessor.get(IAgentLoopService).status().state === 'running' || + agent.accessor.get(IAgentFullCompactionService).compacting !== null + ) { + throw new KimiError( + ErrorCodes.TURN_AGENT_BUSY, + 'Cannot import context while the agent is busy', + ); + } + const message = buildImportContextMessage(input.content, input.source); + const capability = agent.accessor.get(IAgentProfileService).data().modelCapabilities; + const currentTokenCount = agent.accessor.get(IAgentContextSizeService).get().size; + assertImportFits( + message, + currentTokenCount, + capability.max_input_tokens ?? capability.max_context_tokens, + ); + agent.accessor.get(IAgentContextMemoryService).append(message); + } + + /** + * Facade (`agentRPCService.prompt`). The launch result (`{turn_id}`, or + * `undefined` when the prompt queued behind a running turn) is dropped — + * v1's RPC returns void. The pre-provider surface matches v1: the metadata + * update (title/lastPrompt) runs through the same shared helpers before the + * turn launches, and a model-less turn fails asynchronously exactly like + * v1's. Two enqueue-semantics gaps vs v1, pinned in the migration tracker: + * v1 drops a prompt submitted while a turn is active (error event only) + * where v2 queues it FIFO, and v1 never consumes `disabledTools` (the + * payload field reaches the agent RPC but no code reads it) where v2 + * applies it as the session tool denylist. + */ + override async prompt(input: SessionPromptRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + await agent.prompt({ input: input.input, disabledTools: input.disabledTools }); + } + + /** + * Facade (`agentRPCService.steer`). Mid-turn steers match v1 (the input + * joins the running turn). The idle-session case diverges by design and is + * pinned in the parity tests: v1 launches a fresh turn off a steer and + * updates title/lastPrompt like a prompt; v2's enqueue launches the turn + * first, so the follow-up `steer()` finds nothing pending and rejects with + * `prompt.not_found` — and the v2 RPC path never touches the metadata. + */ + override async steer(input: SessionPromptRpcInput): Promise { + const agent = await this.agentFacade(input.sessionId); + await agent.steer({ input: input.input }); + } + + /** + * Facade (`agentShellCommandService.run`) — the same builtin-Bash execution + * and `shell_command`-origin history records as v1, with an identical + * `{stdout, stderr, isError?, backgrounded?}` result shape. The `commandId` + * event stream (`shell.output` / `shell.started` / `shell.completed`) is + * engine-side on both; translating it into SDK events is the event batch's + * job, not this one's. Model-less gap, not pinned: v1's builtin tools only + * exist on a profiled agent, so a model-less v1 session answers "Bash tool + * is not available." where v2 runs the command. + */ + override async runShellCommand(input: { + sessionId: string; + command: string; + commandId?: string; + }): Promise<{ stdout: string; stderr: string; isError?: boolean; backgrounded?: boolean }> { + const agent = await this.agentFacade(input.sessionId); + return agent.runShellCommand({ command: input.command, commandId: input.commandId }); + } + + /** Facade (`agentShellCommandService.cancel`) — an unknown id is a silent no-op on both engines. */ + override async cancelShellCommand(input: { + sessionId: string; + commandId: string; + }): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.cancelShellCommand({ commandId: input.commandId }); + } + + /** + * Through the agent scope (`IAgentSkillService.activate`) — deliberately + * NOT `IAgentRPCService.activateSkill`, whose `void this.skills.activate(...)` + * fire-and-forget turns v1's synchronous rejections (`skill.not_found` / + * `skill.type_unsupported`) into unhandled rejections. The direct call keeps + * v1's semantics: validate first, then render the skill prompt and launch a + * turn with it. v1's session layer then updates title/lastPrompt for the + * MAIN agent only; replicated here over the engine's shared metadata + * helpers. Busy-turn gap vs v1, pinned in the migration tracker: v1 drops + * the activation into an error event while a turn runs; v2's activate + * awaits the queued prompt's launch. + */ + override async activateSkill(input: ActivateSkillRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + await agent.accessor.get(IAgentSkillService).activate({ name: input.name, args: input.args }); + if (this.interactiveAgentId === MAIN_AGENT_ID) { + await this.updatePromptMetadata(input.sessionId, promptMetadataTextFromSkill(input)); + } + } + + /** + * Through the agent scope (`IAgentRPCService.activatePluginCommand`) — the + * v2 RPC surface's own implementation: the same `request.invalid` rejection + * text for an unknown command, the same argument expansion, the activation + * event, the prompt enqueue, and the metadata update. Two gaps vs v1, + * pinned in the migration tracker: v1 resolves the command against the + * session's creation-time snapshot (v2 uses the app-global live view), and + * v1 drops the activation while a turn runs where v2 queues it. v1 also + * updates title/lastPrompt for the main agent only, where the v2 RPC does + * it unconditionally — only observable through a non-main + * `interactiveAgentId`. + */ + override async activatePluginCommand(input: ActivatePluginCommandRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + await agent.accessor.get(IAgentRPCService).activatePluginCommand({ + pluginId: input.pluginId, + commandName: input.commandName, + args: input.args, + }); + } + + /** + * Through the session scope (`ISessionInitService.generateAgentsMd`, the + * engine's port of v1's `Session.generateAgentsMd`) — a session-level + * operation pinned to the main agent on both engines, so + * `interactiveAgentId` does not apply; the main agent is materialized first + * (v1 creates it eagerly at createSession). The success path is a real + * subagent LLM round (`/init` brief), so parity covers only the model-less + * rejection: both engines fail with `session.init_failed`, with different + * messages (v1 wraps the provider-resolution failure, v2 preflights the + * missing binding) — pinned in the parity tests. + */ + override async generateAgentsMd(input: SessionIdRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + await this.materializeMainAgent(session); + await session.accessor.get(ISessionInitService).generateAgentsMd(); + } + + /** + * No v2 service implements the session-warnings aggregate (`ISessionRPCService` + * is an interface without an implementation), so the SDK rebuilds v1's + * `Session.getSessionWarnings` over v2 primitives: the profile's cached + * `agentsMdWarning` (computed on every bind, v1's bootstrap-time cache), + * recomputed through the engine's own `prepareSystemPromptContext` when the + * cache is empty — v1 recomputes on demand whenever no warning is cached, + * so an AGENTS.md that outgrows the budget mid-session surfaces on both + * engines. The single warning shape (`agents-md-oversized`, severity + * `warning`) mirrors v1's assembly. + */ + override async getSessionWarnings(input: SessionIdRpcInput) { + const agent = await this.agentScope(input.sessionId); + let warning = agent.accessor.get(IAgentProfileService).getAgentsMdWarning(); + if (warning === undefined) { + const session = this.requireLiveSession(input.sessionId); + const prepared = await prepareSystemPromptContext( + { + fs: this.engineAccessor.get(IHostFileSystem), + homeDir: this.engineAccessor.get(IHostEnvironment).homeDir, + }, + session.accessor.get(ISessionContext).cwd, + this.engineAccessor.get(IBootstrapService).homeDir, + { additionalDirs: session.accessor.get(ISessionWorkspaceContext).additionalDirs }, + ); + warning = prepared.agentsMdWarning; + } + if (warning === undefined) return []; + return [{ code: 'agents-md-oversized', message: warning, severity: 'warning' as const }]; + } + + /** + * v1's session-layer prompt-metadata update (title/lastPrompt), rebuilt + * over the engine's shared helper so skill/plugin-command activations land + * on the same metadata the native v2 prompt path writes. + */ + private async updatePromptMetadata(sessionId: string, text: string | undefined): Promise { + const session = this.requireLiveSession(sessionId); + await applyPromptMetadataUpdate( + { + metadata: session.accessor.get(ISessionMetadata), + eventService: this.engineAccessor.get(IEventService), + sessionId, + }, + text, + ); + } + + /** + * Through the session scope (`ISessionBtwService`) — no klient facade + * exists. The v2 service is the port of v1's btw fork: same inherited + * profile/context, same byte-identical side-question reminder, same + * tool-call deny, and the same return (the forked child's agent id). The + * main agent is materialized first — both engines fork it as the source, + * and v2's `fork('main')` throws on a missing source. Gaps, pinned in the + * migration tracker: v2 always forks MAIN where v1 forks the agent + * `interactiveAgentId` addresses (SDK hosts only ever btw the main agent), + * and the v2 child is a regular persisted agent where v1's is memory-only + * (`InMemoryAgentRecordPersistence`, no metadata). + */ + override async startBtw(input: SessionIdRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + await this.materializeMainAgent(session); + return session.accessor.get(ISessionBtwService).start(); + } + + /** + * Through the agent scope (`IAgentSwarmService.enter` / `.exit`) — no + * klient facade exists. The v2 service is the port of v1's `SwarmMode`: + * enter is idempotent and injects the byte-identical enter reminder for + * non-`tool` triggers, exit pops that reminder when it is the last message + * (appending the exit reminder otherwise), and `task` / `tool` triggers + * auto-exit on turn end. The base class's private enter/exit pair is + * replaced wholesale; `swarm()` below recomposes it over this override. + */ + override async setSwarmMode(input: SetSessionSwarmModeRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + const swarm = agent.accessor.get(IAgentSwarmService); + if (input.enabled) { + swarm.enter(input.trigger); + return; + } + swarm.exit(); + } + + /** v1's `swarm()` composition: enter with the one-shot `task` trigger, then prompt. */ + override async swarm(input: SessionPromptRpcInput): Promise { + await this.setSwarmMode({ sessionId: input.sessionId, enabled: true, trigger: 'task' }); + return this.prompt(input); + } + + // ----------------------------------------------------------------------- + // Goal / cron / background tasks / print policy + // + // The goal service is the v2 port of v1's `GoalMode` (same state machine, + // same validations, same error codes), so the goal overrides are thin + // forwards through the agent scope. Cron and the task manager moved from + // per-agent (v1) to session/agent-scope services with field-identical + // wire shapes; the two print-policy methods have no v2 service at all + // (the native v2 print runner re-implements the same policy inline), so + // they are rebuilt here over the engine's config helpers and the session's + // per-agent task services. + // ----------------------------------------------------------------------- + + /** + * Through the agent scope (`IAgentGoalService.createGoal`) — no klient + * facade exists for the goal domain. Gap: v2 rejects every goal command on + * a non-main agent (`goal.unsupported_agent`) where v1 keeps a `GoalMode` + * on every agent; only reachable through a non-main `interactiveAgentId` + * (tracked in the migration tracker). + */ + override async createGoal(input: SessionIdRpcInput & CreateGoalInput): Promise { + const agent = await this.agentScope(input.sessionId); + return agent.accessor + .get(IAgentGoalService) + .createGoal({ objective: input.objective, replace: input.replace }); + } + + override async getGoal(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + return agent.accessor.get(IAgentGoalService).getGoal(); + } + + override async pauseGoal(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + return agent.accessor.get(IAgentGoalService).pauseGoal(); + } + + override async resumeGoal(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + return agent.accessor.get(IAgentGoalService).resumeGoal(); + } + + override async cancelGoal(input: SessionIdRpcInput): Promise { + const agent = await this.agentScope(input.sessionId); + return agent.accessor.get(IAgentGoalService).cancelGoal(); + } + + /** + * Through the session scope (`ISessionCronService`) — no klient facade + * exists for cron. v1's cron manager is per-agent: the main agent's + * manager is what the v2 session-level service ports (it borrows the main + * agent to steer fires), and a v1 subagent reports `[]` (`cron` is null) — + * mirrored here for a non-main `interactiveAgentId`. The v1 snapshot shape + * is restored field-by-field: `recurring` defaults to true, and the + * post-jitter `nextFireAt` comes from the same scheduler read v1's + * `listTaskSnapshots` forwards to. + */ + override async getCronTasks(input: SessionIdRpcInput): Promise { + await this.agentScope(input.sessionId); + if (this.interactiveAgentId !== MAIN_AGENT_ID) return { tasks: [] }; + const cron = this.requireLiveSession(input.sessionId).accessor.get(ISessionCronService); + return { + tasks: cron.list().map((task) => ({ + id: task.id, + cron: task.cron, + recurring: task.recurring !== false, + createdAt: task.createdAt, + lastFiredAt: task.lastFiredAt, + nextFireAt: cron.getNextFireForTask(task.id), + })), + }; + } + + /** + * Facade (`agentTaskService.list`). The v2 `AgentTaskInfo` union is the + * same wire shape as v1's `BackgroundTaskInfo` — the process / agent / + * question kinds are field-identical ports — so the cast only bridges the + * two packages' type declarations. One content gap, pinned in the parity + * KNOWN_DIFFS: after a detach, v2 rewrites the reported `timeoutMs` to the + * detach deadline where v1 keeps the foreground one. + */ + override async listBackgroundTasks( + input: SessionIdRpcInput & { activeOnly?: boolean; limit?: number }, + ): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.getTasks({ activeOnly: input.activeOnly, limit: input.limit }) as Promise< + readonly BackgroundTaskInfo[] + >; + } + + /** + * Facade (`agentTaskService.readOutput`) — same unknown-id-returns-`''` + * behavior and the same trailing-characters `tail` semantics as v1. + */ + override async getBackgroundTaskOutput( + input: SessionIdRpcInput & { taskId: string; tail?: number }, + ): Promise { + const agent = await this.agentFacade(input.sessionId); + return agent.getTaskOutput({ taskId: input.taskId, tail: input.tail }); + } + + /** + * Through the agent scope (`IAgentTaskService.stop`) — deliberately NOT the + * facade's `stopTask`, whose no-reason path routes to `stopByUser` and + * stamps a user-cancellation `stopReason` where v1's + * `background.stop(taskId, reason)` records none. The direct call matches + * v1 in both shapes (reason trimmed, blank → undefined). Timing gap: v1 + * fire-and-forgets the stop so its RPC returns before the kill settles; + * the v2 service awaits the termination — a strictly stronger guarantee. + */ + override async stopBackgroundTask( + input: SessionIdRpcInput & { taskId: string; reason?: string }, + ): Promise { + const agent = await this.agentScope(input.sessionId); + await agent.accessor.get(IAgentTaskService).stop(input.taskId, input.reason); + } + + /** + * Through the agent scope (`IAgentTaskService.detach`) — no klient facade + * exists. Same semantics as v1's `background.detach`: releases the + * foreground tool-call waiter, returns the live info (or the ghost / live + * info for an already-terminal task, `undefined` for an unknown id). + */ + override async detachBackgroundTask( + input: SessionIdRpcInput & { taskId: string }, + ): Promise { + const agent = await this.agentScope(input.sessionId); + return agent.accessor.get(IAgentTaskService).detach(input.taskId) as + | BackgroundTaskInfo + | undefined; + } + + /** + * v1's `Session.waitForBackgroundTasksOnPrint`, rebuilt over v2 primitives + * — no v2 service owns the print policy (the native v2 print runner + * re-implements the same drain inline in run-v2-print). Same gate (drain + * mode only), same ceiling, same suppress + wait + re-enumerate loop over + * every live agent's task service. Config timing note: v1 reads the + * `background` section captured at session creation; v2 resolves the live + * config (the `[task]` section layered over `[background]`) — identical + * unless the config changes mid-session. + */ + override async waitForBackgroundTasksOnPrint(input: SessionIdRpcInput): Promise { + const session = this.requireLiveSession(input.sessionId); + await this.configReady; + const config = this.engineAccessor.get(IConfigService); + if (resolvePrintBackgroundMode(config) !== 'drain') return; + const ceilingS = + resolveAgentTaskConfig(config)?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT; + await this.drainBackgroundTasksOnPrint(session, ceilingS); + } + + /** + * v1's `Session.handlePrintMainTurnCompleted`, rebuilt over the same v2 + * primitives: `'exit'` finishes immediately, `'drain'` drains then + * finishes, and `'steer'` keeps the run alive while background tasks are + * pending, bounded by the wall-clock ceiling (`print_wait_ceiling_s`) and + * the turn cap (`print_max_turns`). The steer deadline/turn counters are + * per-session SDK state ({@link printSteerStates}), mirroring v1's + * Session-object fields. + */ + override async handlePrintMainTurnCompleted( + input: SessionIdRpcInput, + ): Promise<'finish' | 'continue'> { + const session = this.requireLiveSession(input.sessionId); + await this.configReady; + const config = this.engineAccessor.get(IConfigService); + const taskConfig = resolveAgentTaskConfig(config); + const ceilingS = taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT; + const mode = resolvePrintBackgroundMode(config); + if (mode === 'exit') return 'finish'; + if (mode === 'drain') { + await this.drainBackgroundTasksOnPrint(session, ceilingS); + return 'finish'; + } + // 'steer' + const maxTurns = taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT; + const state = this.printSteerStates.get(input.sessionId) ?? { deadline: undefined, turns: 0 }; + this.printSteerStates.set(input.sessionId, state); + const now = Date.now(); + state.deadline ??= now + ceilingS * 1000; + state.turns += 1; + if (now >= state.deadline) return 'finish'; + if (state.turns > maxTurns) return 'finish'; + if (this.countActiveBackgroundTasks(session) > 0) return 'continue'; + return 'finish'; + } + + /** + * The shared drain pass of the two print-policy overrides, ported from + * v1's `waitForBackgroundTasksOnPrint` (the native v2 print runner carries + * the same loop): re-enumerate active tasks across every live agent until + * none remain or the ceiling expires — a subagent may fan out new tasks + * mid-drain — with terminal notifications suppressed up front so a + * completing task cannot steer a finished main turn. + */ + private async drainBackgroundTasksOnPrint( + session: ISessionScopeHandle, + ceilingS: number, + ): Promise { + const deadline = Date.now() + ceilingS * 1000; + const seen = new Set(); + const allWaiters: Promise[] = []; + while (Date.now() < deadline) { + const batch: Promise[] = []; + const suppressions: Promise[] = []; + let activeCount = 0; + for (const agent of session.accessor.get(IAgentLifecycleService).list()) { + const tasks = agent.accessor.get(IAgentTaskService); + for (const task of tasks.list(true)) { + activeCount++; + if (seen.has(task.taskId)) continue; + seen.add(task.taskId); + suppressions.push(tasks.suppressTerminalNotification(task.taskId)); + // The engine's `wait` arms a raw `setTimeout(timeoutMs)`, which + // overflows above the ~24.8-day timer ceiling into an immediate + // resolve (v1's `timeoutOutcome` clamps to the same bound) — the + // default print ceiling is 10 years, so clamp here. The outer loop + // re-enumerates after an early return, so semantics are unchanged. + const remaining = Math.min(Math.max(1, deadline - Date.now()), MAX_TIMER_DELAY_MS); + const waiter = tasks.wait(task.taskId, remaining); + batch.push(waiter); + allWaiters.push(waiter); + } + } + if (suppressions.length > 0) await Promise.all(suppressions); + if (activeCount === 0 || batch.length === 0) break; + await Promise.all(batch); + } + if (allWaiters.length > 0) await Promise.all(allWaiters); + } + + /** v1's `countActiveBackgroundTasks`: active tasks across every live agent. */ + private countActiveBackgroundTasks(session: ISessionScopeHandle): number { + let count = 0; + for (const agent of session.accessor.get(IAgentLifecycleService).list()) { + count += agent.accessor.get(IAgentTaskService).list(true).length; + } + return count; + } + + // ----------------------------------------------------------------------- + // MCP: the user-global surface is rebuilt over the SDK-side store port in + // `src/v2/global-mcp.ts` plus the v2 engine's own OAuth service and + // connection manager (agent-core-v2 has no app-scope MCP config service — + // it only reads `mcp.json` — and binds its OAuth orchestrator inside the + // session scope); the session-level reads go through the session scope's + // `ISessionMcpService` (no klient facade exists for either group). + // ----------------------------------------------------------------------- + + /** v1's per-core `globalMcpOAuth`, built over the app-scope document store. */ + private get globalMcpOAuthService(): McpOAuthService { + if (this.globalMcpOAuth === undefined) { + this.globalMcpOAuth = new McpOAuthService({ + store: createMcpOAuthStore(this.engineAccessor.get(IAtomicDocumentStore)), + }); + } + return this.globalMcpOAuth; + } + + override async listGlobalMcpServers(): Promise { + return this.globalMcpConfig.list(); + } + + override async addGlobalMcpServer( + server: McpServerConfig, + ): Promise { + return this.globalMcpConfig.add(server); + } + + override async updateGlobalMcpServer( + server: McpServerConfig, + ): Promise { + return this.globalMcpConfig.update(server); + } + + override async removeGlobalMcpServer(name: string): Promise { + return this.globalMcpConfig.remove(name); + } + + /** + * v1's flow state machine verbatim: the guards (`requireOAuthMcpServer`) + * run before any network I/O, a begun flow is held by flowId until + * complete/cancel, and an already-authorized server short-circuits without + * a flowId. The OAuth work itself is the v2 engine's `McpOAuthService` + * (same begin/complete/cancel contract as v1's). + */ + override async beginGlobalMcpServerAuth(name: string): Promise { + const server = await this.globalMcpConfig.get(name); + const config = requireOAuthMcpServer(server); + try { + const flow = await this.globalMcpOAuthService.beginAuthorization(server.name, config.url); + const flowId = randomUUID(); + this.globalMcpOAuthFlows.set(flowId, { flow }); + return { + status: 'authorization-required', + flowId, + authorizationUrl: flow.authorizationUrl.toString(), + }; + } catch (error) { + if (error instanceof AlreadyAuthorizedError) { + return { status: 'already-authorized' }; + } + throw error; + } + } + + override async completeGlobalMcpServerAuth( + input: { readonly flowId: string; readonly timeoutMs?: number }, + signal?: AbortSignal, + ): Promise { + const active = this.globalMcpOAuthFlows.get(input.flowId); + if (active === undefined) { + throw new KimiError(ErrorCodes.REQUEST_INVALID, `Unknown MCP OAuth flow: ${input.flowId}`); + } + try { + await active.flow.complete({ + signal, + timeoutMs: input.timeoutMs ?? DEFAULT_GLOBAL_MCP_AUTH_TIMEOUT_MS, + }); + } finally { + this.globalMcpOAuthFlows.delete(input.flowId); + } + } + + override async cancelGlobalMcpServerAuth(flowId: string): Promise { + const active = this.globalMcpOAuthFlows.get(flowId); + if (active === undefined) return; + this.globalMcpOAuthFlows.delete(flowId); + await active.flow.cancel(); + } + + override async resetGlobalMcpServerAuth(name: string): Promise { + const server = await this.globalMcpConfig.get(name); + const config = requireRemoteMcpServer(server); + await this.globalMcpOAuthService.invalidate(server.name, config.url); + } + + /** + * v1's standalone probe: a throwaway connection manager over the global + * file entry, never the session's. The global default timeouts come from + * the live `[mcp]` config section (awaited first — the config service's + * reads are synchronous over pre-ready state, the same trap as the config + * batch); v1 resolves the same section with the same env bindings, so the + * precedence matches. + */ + override async testGlobalMcpServer( + name: string, + options: { readonly cwd?: string } = {}, + ): Promise { + const server = await this.globalMcpConfig.get(name); + const config = mcpConfigWithoutName(server); + await this.configReady; + const section = this.engineAccessor.get(IConfigService).get(MCP_SECTION); + const manager = new McpConnectionManager({ + stdioCwd: options.cwd, + oauthService: this.globalMcpOAuthService, + resolveDefaultTimeouts: () => ({ + startupTimeoutMs: section?.startupTimeoutMs, + toolTimeoutMs: section?.toolTimeoutMs, + }), + }); + try { + await manager.connectAll({ [server.name]: config }); + return standaloneMcpTestResult(server.name, manager); + } finally { + await manager.shutdown(); + } + } + + /** + * Through the session scope (`ISessionMcpService.connectionManager()`). + * Both engines settle the initial connect before create/resume returns, so + * the entry list is final here; the v2 `McpServerEntry` is field-identical + * with v1's `McpServerInfo` (the cast bridges the two packages' type + * declarations). + */ + override async listMcpServers(input: SessionIdRpcInput): Promise { + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); + return mcp.connectionManager().list() as readonly McpServerInfo[]; + } + + override async getMcpStartupMetrics(input: SessionIdRpcInput): Promise { + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); + await mcp.connectionManager().waitForInitialLoad(); + return { durationMs: mcp.connectionManager().initialLoadDurationMs() }; + } + + /** + * Same direct `reconnect` as v1's session RPC — the v2 manager raises the + * same `mcp.server_not_found` / `mcp.server_disabled` errors, and the tool + * re-registration rides on the status listeners in both engines. + */ + override async reconnectMcpServer(input: ReconnectMcpServerRpcInput): Promise { + const mcp = this.requireLiveSession(input.sessionId).accessor.get(ISessionMcpService); + await mcp.connectionManager().reconnect(input.name); + } +} + +export function createKimiHarnessV2(options: KimiHarnessOptions): KimiHarness { + const rpc = new SDKRpcClientV2(options); + return new KimiHarness(rpc, { + identity: rpc.identity, + uiMode: options.uiMode, + homeDir: rpc.homeDir, + configPath: rpc.configPath, + auth: rpc.auth, + telemetry: rpc.telemetry, + ensureConfigFile: () => rpc.ensureConfigFile(), + onClose: () => rpc.close(), + // v1-core-owned ingestion limits; the v2 engine has no equivalent yet, so + // ingestion falls back to env / built-in defaults like daemon-client hosts. + imageLimits: undefined, + sessionStartedProperties: options.sessionStartedProperties, + }); +} + +/** v1's `requiredWorkDir`: reject blank and normalize to the canonical spelling. */ +function normalizeRequiredWorkDir(operation: string, workDir: string): string { + if (typeof workDir !== 'string' || workDir.trim() === '') { + throw new KimiError(ErrorCodes.REQUEST_WORK_DIR_REQUIRED, `${operation} requires workDir`); + } + return normalizeWorkDir(workDir); +} diff --git a/packages/node-sdk/src/v2/config-mapper.ts b/packages/node-sdk/src/v2/config-mapper.ts new file mode 100644 index 000000000..43cd8fd4d --- /dev/null +++ b/packages/node-sdk/src/v2/config-mapper.ts @@ -0,0 +1,127 @@ +/** + * v2 config shape mapping — pure functions that project the agent-core-v2 + * engine's per-domain config view (`IConfigService.getAll()` / + * `inspect().userValue` / `diagnostics()`) onto the v1 `KimiConfig` / + * `ConfigDiagnostics` shapes the SDK contract returns. + * + * Why a mapping layer exists: v1 loads config.toml as ONE zod-validated + * document (`KimiConfigSchema`), while v2 registers one config section per + * owning domain and resolves each independently. The v1 top-level field + * names line up 1:1 with the v2 camelCase domain names (both derive from + * the same snake_case TOML keys), so the read mapping is a field pick, not + * a reshape. Gaps that cannot be mapped faithfully (the v1-only `raw` + * passthrough document, v2's materialized section defaults) are pinned in + * the parity test's `KNOWN_DIFFS`, not papered over here. + */ +import type { ConfigDiagnostics, KimiConfig } from '#/types'; + +/** + * Every top-level `KimiConfig` field except `raw` (a v1 write-path + * implementation detail with no v2 counterpart). Each entry is both the v1 + * field name and the v2 config domain name. + */ +const KIMI_CONFIG_DOMAINS = [ + 'providers', + 'defaultProvider', + 'defaultModel', + 'models', + 'thinking', + 'planMode', + 'yolo', + 'defaultPermissionMode', + 'defaultPlanMode', + 'permission', + 'hooks', + 'services', + 'mergeAllAvailableSkills', + 'extraSkillDirs', + 'loopControl', + 'background', + 'subagent', + 'mcp', + 'image', + 'modelCatalog', + 'experimental', + 'telemetry', +] as const; + +/** + * Pick the v1-shaped fields out of the v2 engine's resolved config + * (`config.getAll()` — the effective view: file values plus env overlays + * plus registered section defaults). Domains v2 knows but v1 does not + * (`cron`, `tools`, `secondaryModel`, `extraAgentDirs`, ...) are dropped, + * mirroring how v1's schema strips unknown top-level keys. + */ +export function resolvedConfigToKimiConfig(resolved: Record): KimiConfig { + const config: Record = {}; + for (const domain of KIMI_CONFIG_DOMAINS) { + const value = resolved[domain]; + if (value !== undefined) { + config[domain] = value; + } + } + return config as KimiConfig; +} + +/** Structural minimum of the v2 engine's `ConfigDiagnostic`. */ +export interface V2ConfigDiagnostic { + readonly domain?: string; + readonly severity: string; + readonly message: string; +} + +/** + * v1 reports diagnostics as flat warning strings; v2 carries structured + * `{domain, severity, message}` entries. The SDK contract is the v1 shape, + * so the message texts are the warnings (severity/domain stay available to + * v2-native callers through the klient facade). + */ +export function diagnosticsToConfigDiagnostics( + diagnostics: readonly V2ConfigDiagnostic[], +): ConfigDiagnostics { + return { warnings: diagnostics.map((diagnostic) => diagnostic.message) }; +} + +/** The writes needed to reproduce v1 `removeKimiProvider` semantics. */ +export interface ProviderRemovalPlan { + readonly providers: Record; + readonly models: Record; + readonly clearDefaultModel: boolean; + readonly clearDefaultProvider: boolean; +} + +/** + * Compute the v1 cascade for removing a provider: drop the provider entry, + * drop every model whose `provider` points at it, and clear the default + * pointers when they dangle. The v2 engine's own `providerService.delete` + * only clears the default-provider pointer, so the SDK replays the full v1 + * cascade through the config facade. Inputs are the USER-layer values + * (`inspect().userValue`), matching v1's disk-config write base. + */ +export function planProviderRemoval(input: { + readonly providers: Record | undefined; + readonly models: Record> | undefined; + readonly defaultModel: string | undefined; + readonly defaultProvider: string | undefined; + readonly providerId: string; +}): ProviderRemovalPlan { + const providers = { ...input.providers }; + delete providers[input.providerId]; + + const models: Record = {}; + let removedDefault = false; + for (const [key, model] of Object.entries(input.models ?? {})) { + if (model['provider'] === input.providerId) { + if (input.defaultModel === key) removedDefault = true; + continue; + } + models[key] = model; + } + + return { + providers, + models, + clearDefaultModel: removedDefault, + clearDefaultProvider: input.defaultProvider === input.providerId, + }; +} diff --git a/packages/node-sdk/src/v2/event-mapper.ts b/packages/node-sdk/src/v2/event-mapper.ts new file mode 100644 index 000000000..e004e396c --- /dev/null +++ b/packages/node-sdk/src/v2/event-mapper.ts @@ -0,0 +1,89 @@ +/** + * v2 → v1 event translation for the SDK event channel (pure mapping layer). + * + * The v2 engine's per-agent `IEventBus` publishes `DomainEvent`s whose + * payloads are already v1-protocol-shaped — the same shapes the v1 core emits + * through `Agent.emitEvent` (the run-v2-print runner renders them untranslated + * for the same reason). What the bus does not carry is the + * `sessionId` / `agentId` stamping: the bus is per-agent, so the engine-side + * consumer knows both (kap-server's broadcaster stamps them the same way). + * This module restores the stamping and reconciles the two streams' type + * sets: v2-only types are dropped (the v1 `Event` union is closed), the task + * lifecycle pair is renamed back to the legacy spelling, and the one + * v1-visible fact the v2 engine publishes on the process-global + * `IEventService` (`session.meta.updated`) is unwrapped from its + * `{type, payload}` envelope. + */ +import type { Event } from '@moonshot-ai/agent-core'; +import type { DomainEvent } from '@moonshot-ai/agent-core-v2'; + +/** + * DomainEvent types the v1 SDK event stream never carries: + * - v2-internal facts with no v1 protocol counterpart: `agent.activity.updated` + * (kap-server folds it into the `agent.status.updated` phase slice at the WS + * edge), `context.spliced`, `task.notified`, `plan.revision`, and the + * `permission.approval.*` pair (v1 surfaces approvals through the + * `requestApproval` callback, never as events). + * - `prompt.*`: the v2 prompt service publishes them on the agent bus, but in + * v1 they are synthesized by the daemon services layer onto the global + * `IEventService` — the in-process SDK client never sees them. + */ +const DROPPED_DOMAIN_EVENT_TYPES: ReadonlySet = new Set([ + 'agent.activity.updated', + 'context.spliced', + 'task.notified', + 'plan.revision', + 'permission.approval.requested', + 'permission.approval.resolved', + 'prompt.submitted', + 'prompt.completed', + 'prompt.aborted', + 'prompt.steered', +]); + +/** + * Type renames needed to reproduce the v1 stream: the v1 core emits task + * lifecycle facts under the legacy `background.task.*` spelling where v2 uses + * `task.*`. The payloads are field-identical ports (kap-server fans out both + * spellings; the v1 SDK client only ever saw the legacy one). + */ +const RENAMED_DOMAIN_EVENT_TYPES: Readonly> = { + 'task.started': 'background.task.started', + 'task.terminated': 'background.task.terminated', +}; + +/** + * Translate one agent-bus event into the v1 `Event` shape (payload plus the + * `sessionId` / `agentId` stamping), or `undefined` when the type has no + * place in the v1 stream (see {@link DROPPED_DOMAIN_EVENT_TYPES}). The cast + * only bridges the two packages' type declarations — every type not dropped + * or renamed carries a payload that is field-identical with its v1 protocol + * counterpart. + */ +export function translateDomainEvent( + event: DomainEvent, + sessionId: string, + agentId: string, +): Event | undefined { + if (DROPPED_DOMAIN_EVENT_TYPES.has(event.type)) return undefined; + const type = RENAMED_DOMAIN_EVENT_TYPES[event.type] ?? event.type; + return { ...event, type, sessionId, agentId } as unknown as Event; +} + +/** + * Translate one process-global `IEventService` fact (`{type, payload}` + * envelope) into the v1 `Event` shape. Only `session.meta.updated` crosses: + * it is the one fact v1 publishes through the session RPC (the prompt + * metadata path, with the same payload fields nested under `payload`); every + * other global-bus type is a daemon/WS-edge event the in-process v1 client + * never saw. + */ +export function translateGlobalEvent(event: { + readonly type: string; + readonly payload: unknown; +}): Event | undefined { + if (event.type !== 'session.meta.updated' || typeof event.payload !== 'object') { + return undefined; + } + return { type: event.type, ...event.payload } as unknown as Event; +} diff --git a/packages/node-sdk/src/v2/global-mcp.ts b/packages/node-sdk/src/v2/global-mcp.ts new file mode 100644 index 000000000..8ed70c939 --- /dev/null +++ b/packages/node-sdk/src/v2/global-mcp.ts @@ -0,0 +1,250 @@ +/** + * The v1 user-global MCP surface (`/mcp.json` CRUD plus the + * standalone connection probe), rebuilt for the v2 client. + * + * Why a replica exists: agent-core-v2 only READS the user-global file (its + * session config loader merges it with the project files); nothing in the + * engine writes it, there is no app-scope MCP config service, and the v2 + * OAuth orchestrator / connection manager live behind the session scope — so + * the store, the `require*` guards, and the probe result shaping are ported + * here byte-for-byte from v1 (`agent-core/src/mcp/global-config.ts` and the + * helpers at the bottom of `agent-core/src/rpc/core-impl.ts`). Validation + * keeps using v1's own `McpServerConfigSchema` (the v2 schema dropped the + * `auth: 'oauth'` marker field and would strip it on write). The moving + * parts that DO have v2 counterparts — the OAuth service and the connection + * manager — are the v2 engine's own classes, instantiated by the caller + * (`sdk-rpc-client-v2.ts`); the v2 credential store persists to the same + * on-disk layout as v1 (`/credentials/mcp/-*.json`). + */ +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { + ErrorCodes, + KimiError, + McpServerConfigSchema, + type GlobalMcpServerConfig, + type McpRemoteServerConfig, + type McpServerConfig, +} from '@moonshot-ai/agent-core'; +import type { McpConnectionManager } from '@moonshot-ai/agent-core-v2/agent/mcp/connection-manager'; +import { atomicWrite } from '@moonshot-ai/agent-core-v2/_base/utils/fs'; + +import type { McpTestResult } from '#/types'; + +interface GlobalMcpConfigFile { + readonly raw: Record; + readonly rawServers: Record; + readonly servers: readonly GlobalMcpServerConfig[]; +} + +/** Byte-identical port of v1's `GlobalMcpConfigStore`. */ +export class GlobalMcpConfigStore { + readonly path: string; + + constructor(homeDir: string) { + this.path = join(homeDir, 'mcp.json'); + } + + async list(): Promise { + return (await this.read()).servers; + } + + async get(name: string): Promise { + const normalizedName = normalizeServerName(name); + const server = (await this.read()).servers.find((entry) => entry.name === normalizedName); + if (server !== undefined) return server; + throw serverNotFound(normalizedName); + } + + async add(server: GlobalMcpServerConfig): Promise { + const normalized = parseServerInput(server); + const file = await this.read(); + if (Object.hasOwn(file.rawServers, normalized.name)) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `MCP server "${normalized.name}" already exists`, + ); + } + await this.write(file, { + ...file.rawServers, + [normalized.name]: persistedEntry(normalized), + }); + return this.list(); + } + + async update(server: GlobalMcpServerConfig): Promise { + const normalized = parseServerInput(server); + const file = await this.read(); + if (!Object.hasOwn(file.rawServers, normalized.name)) { + throw serverNotFound(normalized.name); + } + await this.write(file, { + ...file.rawServers, + [normalized.name]: persistedEntry(normalized), + }); + return this.list(); + } + + async remove(name: string): Promise { + const normalizedName = normalizeServerName(name); + const file = await this.read(); + if (!Object.hasOwn(file.rawServers, normalizedName)) return file.servers; + const nextServers = Object.fromEntries( + Object.entries(file.rawServers).filter(([entryName]) => entryName !== normalizedName), + ); + await this.write(file, nextServers); + return this.list(); + } + + private async read(): Promise { + let text: string; + try { + text = await readFile(this.path, 'utf-8'); + } catch (error: unknown) { + if (errorCode(error) === 'ENOENT') { + return { raw: {}, rawServers: {}, servers: [] }; + } + throw configError(`Failed to read ${this.path}: ${describeError(error)}`, error); + } + + if (text.trim().length === 0) { + return { raw: {}, rawServers: {}, servers: [] }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch (error: unknown) { + throw configError(`Invalid JSON in ${this.path}: ${describeError(error)}`, error); + } + if (!isRecord(parsed)) { + throw configError(`Invalid MCP config in ${this.path}: expected a JSON object`); + } + const rawServersValue = parsed['mcpServers']; + if (rawServersValue !== undefined && !isRecord(rawServersValue)) { + throw configError(`Invalid MCP config in ${this.path}: "mcpServers" must be an object`); + } + const rawServers = rawServersValue ?? {}; + const servers = Object.entries(rawServers).map(([name, value]) => parseServer(name, value)); + return { raw: parsed, rawServers, servers }; + } + + private async write( + file: GlobalMcpConfigFile, + rawServers: Record, + ): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + await atomicWrite( + this.path, + `${JSON.stringify({ ...file.raw, mcpServers: rawServers }, null, 2)}\n`, + ); + } +} + +/** Byte-identical port of v1's `requireRemoteMcpServer` guard. */ +export function requireRemoteMcpServer(server: GlobalMcpServerConfig): McpRemoteServerConfig { + const config = mcpConfigWithoutName(server); + if (config.transport !== 'stdio') return config; + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `MCP server "${server.name}" does not use a remote transport`, + ); +} + +/** Byte-identical port of v1's `requireOAuthMcpServer` guard. */ +export function requireOAuthMcpServer(server: GlobalMcpServerConfig): McpRemoteServerConfig { + const config = requireRemoteMcpServer(server); + if (config.bearerTokenEnvVar !== undefined) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `MCP server "${server.name}" uses a static bearer token`, + ); + } + if (config.headers !== undefined && config.auth !== 'oauth') { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `MCP server "${server.name}" uses static headers and is not marked for OAuth`, + ); + } + return config; +} + +/** Byte-identical port of v1's `mcpConfigWithoutName`. */ +export function mcpConfigWithoutName(server: GlobalMcpServerConfig): McpServerConfig { + const { name: _name, ...config } = server; + return config; +} + +/** + * Byte-identical port of v1's `standaloneMcpTestResult`, typed against the + * v2 engine's connection manager (the probe's `get` / `resolved` reads are + * the same on both ports of the manager). + */ +export function standaloneMcpTestResult( + name: string, + manager: McpConnectionManager, +): McpTestResult { + const entry = manager.get(name); + if (entry?.status !== 'connected') { + return { + success: false, + output: + entry?.error ?? `MCP server "${name}" finished with status ${entry?.status ?? 'unknown'}`, + }; + } + const tools = manager.resolved(name)?.rawTools ?? []; + const lines = [ + `Connected to MCP server "${name}".`, + `Available tools: ${tools.length}`, + ...tools.map((tool) => `- ${tool.name}${tool.description ? `: ${tool.description}` : ''}`), + ]; + return { success: true, output: lines.join('\n') }; +} + +function parseServerInput(server: GlobalMcpServerConfig): GlobalMcpServerConfig { + return parseServer(normalizeServerName(server.name), server); +} + +function parseServer(name: string, value: unknown): GlobalMcpServerConfig { + const result = McpServerConfigSchema.safeParse(value); + if (!result.success) { + throw configError( + `Invalid MCP server "${name}" in global config: ${result.error.message}`, + result.error, + ); + } + return { name, ...result.data }; +} + +function persistedEntry(server: GlobalMcpServerConfig): McpServerConfig { + const { name: _name, ...entry } = server; + return entry; +} + +function normalizeServerName(name: string): string { + const normalized = name.trim(); + if (normalized.length > 0) return normalized; + throw new KimiError(ErrorCodes.REQUEST_INVALID, 'MCP server name cannot be empty'); +} + +function serverNotFound(name: string): KimiError { + return new KimiError(ErrorCodes.MCP_SERVER_NOT_FOUND, `MCP server "${name}" was not found`); +} + +function configError(message: string, cause?: unknown): KimiError { + return new KimiError(ErrorCodes.CONFIG_INVALID, message, { cause }); +} + +function errorCode(error: unknown): unknown { + if (!isRecord(error)) return undefined; + return error['code']; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/node-sdk/src/v2/import-context.ts b/packages/node-sdk/src/v2/import-context.ts new file mode 100644 index 000000000..5b17a7bbc --- /dev/null +++ b/packages/node-sdk/src/v2/import-context.ts @@ -0,0 +1,110 @@ +/** + * v2 import-context construction — pure functions that replicate, byte for + * byte, the user message v1's `ContextMemory.importContext` + * (`packages/agent-core/src/agent/context/index.ts`) appends for an + * `importContext` RPC, plus its validation and overflow rejection. + * + * Why a replica exists: the v2 engine has no import-context capability of its + * own (nothing under `agent-core-v2` builds this message), but all of its + * primitives — the same wire `context.append_message` Op, the same token + * estimator, the same model capabilities — are available, so the SDK composes + * the v1 behavior on top of them. The wrapper format, the guidance text, and + * the two XML escapers below are copied from v1 (`agent/core/src/agent/context` + * and `agent-core/src/utils/xml-escape.ts`); keep them byte-identical with + * those sources so a v1-written and a v2-written import reduce to the same + * history. + */ +import { ErrorCodes, KimiError } from '@moonshot-ai/agent-core'; +import type { ContextMessage } from '@moonshot-ai/agent-core-v2'; +import { estimateTokensForMessages } from '@moonshot-ai/agent-core-v2/kosong/contract/tokens'; + +/** Byte-identical with v1's `IMPORT_CONTEXT_GUIDANCE`. */ +const IMPORT_CONTEXT_GUIDANCE = + 'This is a prior conversation history that may be relevant to the current session. ' + + 'Please review this context and use it to inform your responses.'; + +/** Byte-identical with v1's `escapeXml` (& < > "). */ +function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** Byte-identical with v1's `escapeXmlAttr` (& " only). */ +function escapeXmlAttr(input: string): string { + return input.replaceAll('&', '&').replaceAll('"', '"'); +} + +/** + * The exact message v1 appends for an import, including its rejections: + * blank content (`import_content_empty`) and blank source + * (`import_source_empty`) fail with v1's `request.invalid` shapes before any + * token math runs. + */ +export function buildImportContextMessage(content: string, source: string): ContextMessage { + if (content.trim().length === 0) { + throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Imported context cannot be empty', { + details: { reason: 'import_content_empty' }, + }); + } + const normalizedSource = source.trim(); + if (normalizedSource.length === 0) { + throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Imported context source cannot be empty', { + details: { reason: 'import_source_empty' }, + }); + } + return { + role: 'user', + content: [ + { + type: 'text', + text: + `The user has imported context from ${escapeXml(normalizedSource)}. ` + + `${IMPORT_CONTEXT_GUIDANCE}`, + }, + { + type: 'text', + text: + `\n` + + `${content}\n`, + }, + ], + toolCalls: [], + origin: { kind: 'user' }, + }; +} + +/** + * v1's overflow gate: the import estimate plus the current context must fit + * the model window (unknown window = `0` skips the check on both engines). + * The estimator is the same character heuristic on both sides, so the counts + * — and therefore the rejection — agree. + */ +export function assertImportFits( + message: ContextMessage, + currentTokenCount: number, + maxContextTokens: number, +): void { + const importTokenCount = estimateTokensForMessages([message]); + const totalTokenCount = currentTokenCount + importTokenCount; + if (maxContextTokens > 0 && totalTokenCount > maxContextTokens) { + throw new KimiError( + ErrorCodes.CONTEXT_OVERFLOW, + 'Imported content is too large for the current model context ' + + `(~${String(importTokenCount)} import tokens + ~${String(currentTokenCount)} existing ` + + `= ~${String(totalTokenCount)} total > ${String(maxContextTokens)} token limit). ` + + 'Please import a smaller file or session.', + { + details: { + reason: 'import_context_overflow', + importTokenCount, + currentTokenCount, + totalTokenCount, + maxContextTokens, + }, + }, + ); + } +} diff --git a/packages/node-sdk/src/v2/resume-replay.ts b/packages/node-sdk/src/v2/resume-replay.ts new file mode 100644 index 000000000..6b4c19581 --- /dev/null +++ b/packages/node-sdk/src/v2/resume-replay.ts @@ -0,0 +1,142 @@ +/** + * Resume replay fold — rebuilds the v1 `ResumedAgentState.replay` / + * `toolStore` pair from a v2 agent's `wire.jsonl`. + * + * The v2 engine persists each agent's journal at + * `/agents//wire.jsonl` using v1's record vocabulary for + * every replay-relevant op (see `agent-core-v2/src/wire/record.ts` and the op + * schemas — e.g. `todoOps.ts` states the on-disk vocabulary stays exactly + * v1's so either engine's reader rebuilds the same state). The v1 engine's + * own restore pipeline (`Agent` + `AgentRecords.replay`) is therefore the + * correct fold: it owns the subtle semantics a re-implementation would drift + * from — assistant-message assembly from loop events (`step.begin` opens an + * assistant message, `content.part` / `tool.call` mutate it in place, + * `tool.result` closes the exchange, mid-history gaps are closed with + * synthesized interrupted results, messages deferred behind an open tool + * exchange flush in order), `context.undo` removing replayed messages, and + * `context.apply_compaction` patching the last compaction record with its + * result. + * + * Record-type → replay-record mapping (all produced by the v1 restore; the + * fold inherits it verbatim): + * - `context.append_message` → `{type:'message'}` (same for the + * assistant/tool messages assembled out of `context.append_loop_event`) + * - `full_compaction.begin` → `{type:'compaction', instruction}`; + * `context.apply_compaction` patches the last one with `result`; + * `full_compaction.cancel` marks it `'cancelled'` + * - `goal.create` / `goal.update` → `{type:'goal_updated'}` (`created` / + * `lifecycle` / `completion` change) + * - `plan_mode.enter` → `{type:'plan_updated', enabled:true}`; + * `plan_mode.cancel` / `plan_mode.exit` → `enabled:false` + * - `config.update` → `{type:'config_updated', config}` (the raw + * record fields, including `type`/`time` — v1's restore quirk) + * - `permission.set_mode` → `{type:'permission_updated', mode}` + * - `permission.record_approval_result` → `{type:'approval_result', record}` + * - `tools.update_store` → no replay record; last-wins into the tool + * store returned alongside (v1's `agent.tools.storeData()`) + * - everything else (`metadata`, `turn.*`, `usage.record`, + * `tools.set_active_tools`, `context.update_token_count`, loop bookkeeping) + * rebuilds state only; v2-only ops (`profile.bind`, `plan.revision`, + * `task.started` / `task.terminated`, `skill.activate`, `interaction.*`, + * `context_size.measured`, `llm.*`) fall through v1's restore switch + * untouched. Two consequences: the v2 profile BINDING never appears as a + * `config_updated` replay record (v1 persists the bind as `config.update`, + * v2 as `profile.bind` — pinned in the parity KNOWN_DIFFS), and background + * tasks do NOT come from this fold (v1 restores them from a side file, v2 + * from `task.*` ops) — the caller reads them from the live agent scope. + * + * The fold runs on a THROWAWAY v1 `Agent` over an in-memory persistence, so + * it never mutates the journal (a live restore appends synthesized + * interrupted-tool-result records at `finishResume`; here those appends land + * in the memory buffer only). Any failure — missing/corrupt file, newer + * protocol, unexpected record — degrades to an empty result instead of + * failing the session resume. + */ + +import { readFile } from 'node:fs/promises'; + +import { + Agent, + type AgentRecord, + type AgentRecordPersistence, + type AgentReplayRecord, +} from '@moonshot-ai/agent-core'; +import { LocalKaos } from '@moonshot-ai/kaos'; + +export interface FoldedAgentReplay { + readonly replay: readonly AgentReplayRecord[]; + readonly toolStore: Readonly>; +} + +const EMPTY_FOLD: FoldedAgentReplay = { replay: [], toolStore: {} }; + +/** + * Read-only `AgentRecordPersistence` over an already-parsed journal. The + * throwaway fold agent's live writes (the synthesized interrupted-tool-result + * records `finishResume` appends) land here and go nowhere — the on-disk + * journal is never mutated. `InMemoryAgentRecordPersistence` would do, but + * the package root only exports the interface, and node-sdk's vitest aliases + * `@moonshot-ai/agent-core` to its index, which swallows every deep subpath. + */ +class ReadOnlyAgentRecordPersistence implements AgentRecordPersistence { + constructor(private readonly records: readonly AgentRecord[]) {} + + async *read(): AsyncIterable { + for (const record of this.records) { + yield record; + } + } + + append(_input: AgentRecord): void {} + + rewrite(_records: readonly AgentRecord[]): void {} + + async flush(): Promise {} + + async close(): Promise {} +} + +/** + * Fold one agent's `wire.jsonl` into the v1 replay records and tool-store + * snapshot. Best-effort: unreadable or malformed journals yield an empty + * fold, never a rejected resume. + */ +export async function foldAgentWireReplay(wirePath: string): Promise { + try { + const records = parseWireRecords(await readFile(wirePath, 'utf-8')); + if (records.length === 0) return EMPTY_FOLD; + const agent = new Agent({ + kaos: await LocalKaos.create(), + persistence: new ReadOnlyAgentRecordPersistence(records), + type: 'sub', + }); + await agent.resume({ rewriteMigratedRecords: false }); + return { + replay: agent.replayBuilder.buildResult(), + toolStore: agent.tools.storeData(), + }; + } catch { + return EMPTY_FOLD; + } +} + +/** + * The v1 line reader's rules: blank lines skipped, a truncated TAIL line + * tolerated (the last write may have crashed mid-flush), corruption anywhere + * else is an error. + */ +function parseWireRecords(content: string): AgentRecord[] { + const lines = content.split('\n'); + const records: AgentRecord[] = []; + for (const [index, rawLine] of lines.entries()) { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (line.length === 0) continue; + try { + records.push(JSON.parse(line) as AgentRecord); + } catch (error) { + if (index === lines.length - 1) break; + throw error; + } + } + return records; +} diff --git a/packages/node-sdk/src/v2/session-mapper.ts b/packages/node-sdk/src/v2/session-mapper.ts new file mode 100644 index 000000000..293f67a55 --- /dev/null +++ b/packages/node-sdk/src/v2/session-mapper.ts @@ -0,0 +1,96 @@ +/** + * Pure mapping between the agent-core-v2 session shapes and the v1 SDK wire + * shapes. Two gaps are bridged here: + * - the v2 `ISessionIndex` summary carries no filesystem facts, so the v1 + * `SessionSummary`'s `workDir` / `sessionDir` come in as pre-resolved + * `SessionSummaryFacts` (the caller derives them from `ISessionContext`, + * `IBootstrapService.sessionDir`, and the workspace catalog); + * - the v2 `SessionMeta` document keeps epoch-ms timestamps and a `cwd` field + * where the v1 `SessionMeta` keeps ISO strings and `workDir`. + * Everything else is a field rename (`custom` ↔ `metadata`). + */ +import type { AgentMeta, SessionMeta } from '@moonshot-ai/agent-core'; +import type { + AgentMeta as V2AgentMeta, + SessionMeta as V2SessionMeta, + SessionSummary as V2SessionSummary, +} from '@moonshot-ai/agent-core-v2'; + +import { resolve, win32 } from 'node:path'; + +import type { JsonObject, SessionSummary } from '#/types'; + +/** + * Mirror of v1's `normalizeWorkDir` (`agent-core/session/store/workdir-key`): + * Windows-shaped paths resolve through `win32` and fold to forward slashes, + * everything else resolves against the process cwd. Duplicated here because + * the SDK test config aliases `@moonshot-ai/agent-core` to its index, which + * blocks the deep import — keep it byte-identical to the v1 original. + */ +export function normalizeWorkDir(workDir: string): string { + if (/^[A-Za-z]:[\\/]/.test(workDir) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(workDir)) { + return win32.resolve(workDir).replaceAll('\\', '/'); + } + return resolve(workDir); +} + +/** v1 summary fields the v2 index summary does not carry, resolved by the caller. */ +export interface SessionSummaryFacts { + readonly workDir: string; + readonly sessionDir: string; + readonly additionalDirs?: readonly string[]; +} + +export function v2SummaryToSessionSummary( + summary: V2SessionSummary, + facts: SessionSummaryFacts, +): SessionSummary { + return { + id: summary.id, + title: summary.title, + lastPrompt: summary.lastPrompt, + workDir: facts.workDir, + sessionDir: facts.sessionDir, + createdAt: summary.createdAt, + updatedAt: summary.updatedAt, + archived: summary.archived, + metadata: summary.custom as JsonObject | undefined, + additionalDirs: facts.additionalDirs, + }; +} + +/** + * v1's `SessionMeta` types `title` / `isCustomTitle` / `custom` as required; + * a v2 document that never set them reports the same neutral values v1's + * defaults produce (`''` / `false` / `{}`). Timestamps convert epoch ms → ISO. + */ +export function v2MetaToSessionMeta(meta: V2SessionMeta): SessionMeta { + return { + createdAt: new Date(meta.createdAt).toISOString(), + updatedAt: new Date(meta.updatedAt).toISOString(), + title: meta.title ?? '', + isCustomTitle: meta.isCustomTitle ?? false, + lastPrompt: meta.lastPrompt, + forkedFrom: meta.forkedFrom, + workDir: meta.cwd, + agents: v2AgentsToV1(meta.agents ?? {}), + custom: (meta.custom ?? {}) as Record, + }; +} + +function v2AgentsToV1(agents: Readonly>): Record { + const mapped: Record = {}; + for (const [agentId, agent] of Object.entries(agents)) { + mapped[agentId] = { + homedir: agent.homedir, + // v2 registers every agent with a type; the fallbacks only cover + // documents written before that registration existed. + type: agent.type ?? (agentId === 'main' ? 'main' : 'sub'), + // v1 persists an explicit null for a parentless agent where v2 leaves + // the field unset. + parentAgentId: agent.parentAgentId ?? null, + swarmItem: agent.swarmItem, + }; + } + return mapped; +} diff --git a/packages/node-sdk/src/v2/session-wiring.ts b/packages/node-sdk/src/v2/session-wiring.ts new file mode 100644 index 000000000..ad5fedc41 --- /dev/null +++ b/packages/node-sdk/src/v2/session-wiring.ts @@ -0,0 +1,252 @@ +/** + * Per-live-session event/interaction wiring for the v2 client. + * + * One wiring instance per live session scope, created by `SDKRpcClientV2` + * when a session materializes (create / resume / fork / reload) and disposed + * when it closes. Two responsibilities: + * + * 1. Event forwarding: subscribe every live agent's `IEventBus` (the agents + * present at wiring time plus every later `onDidCreate`, so subagents that + * appear mid-turn are covered) and push each event through + * {@link translateDomainEvent} into the client's `receiveEvent` — the same + * synchronous, in-emission-order delivery v1's push model has (both engines + * dispatch to listeners inside the emitter's call stack). + * 2. The approval / question / user-tool bridge: v1's engine calls the + * client's `requestApproval` / `requestQuestion` / `toolCall` callbacks + * (push), where v2 parks a pending interaction in the session's interaction + * kernel and waits for a response (pull). The bridge watches + * `onDidChangePending`, feeds each new pending interaction to the client + * callback — the base class's own public method, so the v1 semantics (the + * no-handler cancellation, the handler-failure error event) are inherited + * verbatim — and writes the outcome back through the typed session + * services. The kernel's `respond` no-ops on an id that is no longer + * pending, so a late answer after a turn cancellation is safe. + */ +import type { + ApprovalRequest, + ApprovalResponse, + Event, + QuestionRequest, + QuestionResult, + ToolCallRequest, + ToolCallResponse, + ToolInputDisplay, +} from '@moonshot-ai/agent-core'; +import { + IAgentLifecycleService, + IEventBus, + ISessionApprovalService, + ISessionInteractionService, + ISessionQuestionService, + MAIN_AGENT_ID, + type IAgentScopeHandle, + type IDisposable, + type Interaction, + type ISessionScopeHandle, +} from '@moonshot-ai/agent-core-v2'; + +import { translateDomainEvent } from '#/v2/event-mapper'; + +/** + * The client surface the wiring drives — the base class's own public methods, + * so the v1 handler semantics are reused rather than re-implemented. + */ +export interface SessionEventSink { + receiveEvent(event: Event): void; + requestApproval( + request: ApprovalRequest & { sessionId: string; agentId: string }, + ): Promise; + requestQuestion( + request: QuestionRequest & { sessionId: string; agentId: string }, + ): Promise; + toolCall(request: ToolCallRequest): Promise; +} + +/** + * The v2 approval payload (`agent-core-v2/src/session/approval/approval.ts` — + * the package index exports only the service identifier, not the model). A + * superset of v1's `ApprovalRequest`: the extra id/sessionId/agentId fields + * are stripped when the handler is fed. + */ +interface ApprovalInteractionPayload { + readonly id?: string; + readonly sessionId?: string; + readonly agentId?: string; + readonly turnId?: number; + readonly toolCallId?: string; + readonly toolName: string; + readonly action: string; + readonly display: ToolInputDisplay; +} + +/** The v2 question payload (`agent-core-v2/src/session/question/question.ts`). */ +interface QuestionInteractionPayload { + readonly id?: string; + readonly turnId?: number; + readonly toolCallId?: string; + readonly questions: QuestionRequest['questions']; +} + +/** The v2 user-tool execution payload (`agent-core-v2/src/agent/userTool/userToolService.ts`). */ +interface UserToolInteractionPayload { + readonly turnId: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; +} + +export class SessionEventWiring { + private readonly disposables: IDisposable[] = []; + private readonly agentSubscriptions = new Map(); + /** Pending interactions already handed to the sink (the kernel re-fires the full pending set on every change). */ + private readonly bridgedInteractionIds = new Set(); + private disposed = false; + + constructor( + private readonly session: ISessionScopeHandle, + private readonly sink: SessionEventSink, + ) { + const interactions = session.accessor.get(ISessionInteractionService); + this.disposables.push( + interactions.onDidChangePending(() => { + this.bridgeNewPendingInteractions(); + }), + ); + const lifecycle = session.accessor.get(IAgentLifecycleService); + this.disposables.push( + lifecycle.onDidCreate((agent) => { + this.attachAgent(agent); + }), + lifecycle.onDidDispose((agentId) => { + this.detachAgent(agentId); + }), + ); + for (const agent of lifecycle.list()) { + this.attachAgent(agent); + } + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const disposable of this.disposables) { + disposable.dispose(); + } + for (const subscription of this.agentSubscriptions.values()) { + subscription.dispose(); + } + this.agentSubscriptions.clear(); + } + + private attachAgent(agent: IAgentScopeHandle): void { + if (this.disposed || this.agentSubscriptions.has(agent.id)) return; + const sessionId = this.session.id; + const agentId = agent.id; + this.agentSubscriptions.set( + agentId, + agent.accessor.get(IEventBus).subscribe((event) => { + const translated = translateDomainEvent(event, sessionId, agentId); + if (translated !== undefined) this.sink.receiveEvent(translated); + }), + ); + } + + private detachAgent(agentId: string): void { + const subscription = this.agentSubscriptions.get(agentId); + if (subscription === undefined) return; + this.agentSubscriptions.delete(agentId); + subscription.dispose(); + } + + private bridgeNewPendingInteractions(): void { + if (this.disposed) return; + const pending = this.session.accessor.get(ISessionInteractionService).listPending(); + for (const interaction of pending) { + if (this.bridgedInteractionIds.has(interaction.id)) continue; + this.bridgedInteractionIds.add(interaction.id); + switch (interaction.kind) { + case 'approval': + void this.bridgeApproval(interaction); + break; + case 'question': + void this.bridgeQuestion(interaction); + break; + case 'user_tool': + void this.bridgeUserTool(interaction); + break; + } + } + } + + /** + * Feed a pending approval to the client's approval handler (through the + * base-class `requestApproval`, which owns the no-handler cancellation and + * the handler-failure error event) and decide the kernel request with the + * outcome. The kernel notification fires synchronously at park time, so the + * handler is invoked at the same relative moment as v1's push. + */ + private async bridgeApproval(interaction: Interaction): Promise { + const payload = interaction.payload as ApprovalInteractionPayload; + try { + const response = await this.sink.requestApproval({ + turnId: payload.turnId, + toolCallId: payload.toolCallId ?? interaction.id, + toolName: payload.toolName, + action: payload.action, + display: payload.display, + sessionId: this.session.id, + agentId: payload.agentId ?? interaction.origin.agentId ?? MAIN_AGENT_ID, + }); + this.session.accessor.get(ISessionApprovalService).decide(interaction.id, response); + } catch { + // The session scope died mid-bridge (close/reload): the parked engine + // request died with it, and `respond` no-ops on an unknown id anyway. + } + } + + /** + * Same bridge for a pending question: the base-class `requestQuestion` + * answers `null` when no handler is registered or the handler failed — + * mapped onto the kernel's dismiss, which is how both engines' ask-user + * tool reads an unanswered question. + */ + private async bridgeQuestion(interaction: Interaction): Promise { + const payload = interaction.payload as QuestionInteractionPayload; + try { + const result = await this.sink.requestQuestion({ + turnId: payload.turnId, + toolCallId: payload.toolCallId, + questions: payload.questions, + sessionId: this.session.id, + agentId: interaction.origin.agentId ?? MAIN_AGENT_ID, + }); + const questions = this.session.accessor.get(ISessionQuestionService); + if (result === null) { + questions.dismiss(interaction.id); + } else { + questions.answer(interaction.id, result); + } + } catch { + // See bridgeApproval. + } + } + + /** + * Same bridge for a user-tool execution: v1 routes custom tool calls to the + * client's `toolCall` callback (the base class answers "not supported" with + * an error output); without this the v2 tool would wait forever. + */ + private async bridgeUserTool(interaction: Interaction): Promise { + const payload = interaction.payload as UserToolInteractionPayload; + try { + const result = await this.sink.toolCall({ + turnId: payload.turnId, + toolCallId: payload.toolCallId, + args: payload.args, + }); + this.session.accessor.get(ISessionInteractionService).respond(interaction.id, result); + } catch { + // See bridgeApproval. + } + } +} diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts new file mode 100644 index 000000000..ca5085793 --- /dev/null +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -0,0 +1,266 @@ +/** + * Scenario: v2 wiring MVP — the harness talks to the in-process agent-core-v2 + * engine (klient memory transport) instead of the v1 KimiCore RPC pair. + * Responsibilities: `getExperimentalFeatures` is migrated end-to-end; every + * not-yet-migrated method fails loudly with `not_implemented` instead of + * silently hitting a v1 core. + * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; no provider calls. + * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createKimiHarnessV2, ErrorCodes, KimiError, KimiHarness, SDKRpcClientV2 } from '#/index'; +import { foldAgentWireReplay } from '#/v2/resume-replay'; + +import { TEST_IDENTITY } from './test-identity'; +import { recordingTelemetry, type TelemetryRecord } from './telemetry'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + return { harness: createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }), homeDir }; +} + +describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { + it('serves getExperimentalFeatures from the v2 engine', async () => { + const { harness } = await makeHarness(); + try { + const features = await harness.getExperimentalFeatures(); + expect(Array.isArray(features)).toBe(true); + expect(features.length).toBeGreaterThan(0); + for (const feature of features) { + expect(typeof feature.id).toBe('string'); + expect(typeof feature.title).toBe('string'); + expect(typeof feature.env).toBe('string'); + expect(typeof feature.enabled).toBe('boolean'); + expect(typeof feature.defaultEnabled).toBe('boolean'); + } + } finally { + await harness.close(); + } + }); + + it('serves listWorkspaceSkills through the engineAccessor escape hatch', async () => { + const { harness, homeDir } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeSkill(join(homeDir, 'skills', 'demo-user-skill'), 'demo-user-skill'); + await writeSkill(join(workDir, '.kimi-code', 'skills', 'demo-project-skill'), 'demo-project-skill'); + try { + const skills = await harness.listWorkspaceSkills(workDir); + const byName = new Map(skills.map((skill) => [skill.name, skill])); + expect(byName.get('demo-user-skill')).toMatchObject({ + description: 'Skill demo-user-skill for the escape-hatch test', + source: 'user', + }); + expect(byName.get('demo-project-skill')).toMatchObject({ + description: 'Skill demo-project-skill for the escape-hatch test', + source: 'project', + }); + } finally { + await harness.close(); + } + }); + + it('honors skillDirs (explicit dirs) over default user / project discovery', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const explicitBase = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-explicit-')); + tempDirs.push(explicitBase); + const explicitDir = join(explicitBase, 'skills'); + await writeSkill(join(homeDir, 'skills', 'demo-user-skill'), 'demo-user-skill'); + await writeSkill(join(workDir, '.kimi-code', 'skills', 'demo-project-skill'), 'demo-project-skill'); + await writeSkill(join(explicitDir, 'demo-explicit-skill'), 'demo-explicit-skill'); + const harness = createKimiHarnessV2({ + homeDir, + identity: TEST_IDENTITY, + skillDirs: [explicitDir], + }); + try { + const skills = await harness.listWorkspaceSkills(workDir); + const byName = new Map(skills.map((skill) => [skill.name, skill])); + expect(byName.get('demo-explicit-skill')).toMatchObject({ + description: 'Skill demo-explicit-skill for the escape-hatch test', + source: 'user', + }); + expect(byName.has('demo-user-skill')).toBe(false); + expect(byName.has('demo-project-skill')).toBe(false); + + // The session skill catalog (the Skill tool's listing) goes through the + // seeded engine runtime options, so it sees the same explicit source. + const session = await harness.createSession({ workDir }); + const sessionNames = new Set((await session.listSkills()).map((skill) => skill.name)); + expect(sessionNames.has('demo-explicit-skill')).toBe(true); + expect(sessionNames.has('demo-user-skill')).toBe(false); + expect(sessionNames.has('demo-project-skill')).toBe(false); + await session.close(); + } finally { + await harness.close(); + } + }); + + it('serves the plugin catalog from the v2 engine on an empty home', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + try { + expect(await rpc.listPlugins()).toEqual([]); + expect(await rpc.reloadPlugins()).toEqual({ added: [], removed: [], errors: [] }); + await expect(rpc.getPluginInfo('missing-plugin')).rejects.toThrow(); + } finally { + await rpc.close(); + } + }); + + it('fails loudly with not_implemented for methods not yet migrated', async () => { + const { harness } = await makeHarness(); + try { + // `deleteSession` is the permanent case: the v2 engine has no + // session-deletion capability, so it stays not_implemented by design + // (tracked in `.tmp/v2-migration-tracker.md`). + await expect(harness.deleteSession('session_missing')).rejects.toThrowError(KimiError); + await expect(harness.deleteSession('session_missing')).rejects.toMatchObject({ + code: ErrorCodes.NOT_IMPLEMENTED, + }); + } finally { + await harness.close(); + } + }); +}); + +describe('foldAgentWireReplay', () => { + it('folds a journal into v1 replay records and the tool store', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-fold-')); + tempDirs.push(dir); + const wirePath = join(dir, 'wire.jsonl'); + const records = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1000 }, + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + time: 1001, + }, + { type: 'permission.set_mode', mode: 'auto', time: 1002 }, + { + type: 'tools.update_store', + key: 'todo', + value: [{ title: 'old', status: 'done' }], + time: 1003, + }, + { + type: 'tools.update_store', + key: 'todo', + value: [{ title: 'new', status: 'pending' }], + time: 1004, + }, + // A v2-only op the v1 restore switch does not know: ignored. + { type: 'profile.bind', profileName: 'agent', systemPrompt: 'x', thinkingEffort: 'off', disallowedTools: [], time: 1005 }, + ]; + await writeFile(wirePath, records.map((record) => JSON.stringify(record)).join('\n') + '\n', 'utf-8'); + const folded = await foldAgentWireReplay(wirePath); + expect(folded.replay).toEqual([ + { + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + time: 1001, + }, + { type: 'permission_updated', mode: 'auto', time: 1002 }, + ]); + // Last write wins per store key. + expect(folded.toolStore).toEqual({ todo: [{ title: 'new', status: 'pending' }] }); + }); + + it('degrades to an empty fold on a missing or corrupt journal', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-fold-')); + tempDirs.push(dir); + const empty = { replay: [], toolStore: {} }; + await expect(foldAgentWireReplay(join(dir, 'missing.jsonl'))).resolves.toEqual(empty); + const emptyFile = join(dir, 'empty.jsonl'); + await writeFile(emptyFile, '', 'utf-8'); + await expect(foldAgentWireReplay(emptyFile)).resolves.toEqual(empty); + const corrupt = join(dir, 'corrupt.jsonl'); + await writeFile( + corrupt, + '{"type":"metadata","protocol_version":"1.5","created_at":1}\n{not json\n{"type":"permission.set_mode","mode":"auto"}\n', + 'utf-8', + ); + await expect(foldAgentWireReplay(corrupt)).resolves.toEqual(empty); + // A truncated TAIL line is tolerated: everything before it still folds. + const truncatedTail = join(dir, 'truncated.jsonl'); + await writeFile( + truncatedTail, + '{"type":"metadata","protocol_version":"1.5","created_at":1}\n{"type":"permission.set_mode","mode":"auto","time":2}\n{"type":"context.append_messa', + 'utf-8', + ); + const folded = await foldAgentWireReplay(truncatedTail); + expect(folded.replay).toEqual([{ type: 'permission_updated', mode: 'auto', time: 2 }]); + }); +}); + +describe('SDKRpcClientV2 engine telemetry', () => { + it('forwards engine-side events to the host-supplied telemetry client', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-work-')); + tempDirs.push(workDir); + const records: TelemetryRecord[] = []; + const harness = createKimiHarnessV2({ + homeDir, + identity: TEST_IDENTITY, + telemetry: recordingTelemetry(records), + }); + try { + const session = await harness.createSession({ workDir }); + await session.setPermission('yolo'); + expect(records.some((record) => record.event === 'yolo_toggle')).toBe(true); + await session.close(); + } finally { + await harness.close(); + } + }); + + it('honors telemetry = false for engine-side events', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-off-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-off-work-')); + tempDirs.push(workDir); + await writeFile(join(homeDir, 'config.toml'), 'telemetry = false\n', 'utf-8'); + const records: TelemetryRecord[] = []; + const harness = createKimiHarnessV2({ + homeDir, + identity: TEST_IDENTITY, + telemetry: recordingTelemetry(records), + }); + try { + const session = await harness.createSession({ workDir }); + await session.setPermission('yolo'); + expect(records.some((record) => record.event === 'yolo_toggle')).toBe(false); + await session.close(); + } finally { + await harness.close(); + } + }); +}); + +async function writeSkill(dir: string, name: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: Skill ${name} for the escape-hatch test\n---\n\nBody of ${name}.\n`, + 'utf-8', + ); +} diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts new file mode 100644 index 000000000..89b954417 --- /dev/null +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -0,0 +1,4370 @@ +/** + * Scenario: v1↔v2 parity gate — for every SDK method migrated to + * agent-core-v2, the v1 harness (`createKimiHarness`) and the v2 harness + * (`createKimiHarnessV2`) must return identical values on the same fixture + * home. A method only counts as migrated while its comparison here passes; + * temporary, understood gaps are pinned explicitly in `KNOWN_DIFFS` so the + * list can only shrink deliberately, never grow silently. + * Wiring: real v1 core and real v2 engine, both in-process on a temp + * KIMI_CODE_HOME; no provider calls. + * Run: pnpm exec vitest run test/v1-v2-parity.test.ts + */ +import { appendFile, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + ISessionApprovalService, + ISessionLifecycleService, + ISessionQuestionService, +} from '@moonshot-ai/agent-core-v2'; + +import { + createKimiHarness, + createKimiHarnessV2, + ErrorCodes, + SDKRpcClient, + SDKRpcClientV2, + type ApprovalRequest, + type ApprovalResponse, + type BackgroundTaskInfo, + type ConfigDiagnostics, + type Event, + type ExportSessionResult, + type GoalSnapshot, + type GoalToolResult, + type JsonObject, + type KimiConfig, + type KimiHarness, + type McpServerConfig, + type McpStartupMetrics, + type PluginCommandDef, + type PluginInfo, + type PluginSummary, + type QuestionRequest, + type QuestionResult, + type ResumedAgentState, + type ResumedSessionSummary, + type SessionPlan, + type SessionSummary, + type SkillSummary, + type SDKRpcClientBase, +} from '#/index'; + +import { TEST_IDENTITY } from './test-identity'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + // Retry like session-runtime-helpers.removeTempDir: an engine's async + // wire flush can recreate a file while the tree is being removed + // (ENOTEMPTY/EBUSY under load). + for (let attempt = 0; attempt < 10; attempt++) { + try { + await rm(dir, { recursive: true, force: true }); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOTEMPTY' && code !== 'EBUSY' && code !== 'EPERM') throw error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + } +}); + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +/** + * Section values the v2 engine materializes in its effective view + * (`config.getAll()`) even when the file never sets them, while v1's schema + * leaves the same fields absent (only `providers` defaults, on both sides). + * Two sources: registered `defaultValue`s (models/image/subagent/ + * defaultPlanMode/extraSkillDirs/mergeAllAvailableSkills — see the v2 + * `configSection.ts` registrations) and the env-binding pass, which + * materializes `{}` for every section that declares env bindings + * (thinking/services/loopControl/background/mcp) even with no env set. + */ +const V2_INJECTED_SECTION_DEFAULTS: Record = { + models: {}, + image: {}, + thinking: {}, + services: {}, + loopControl: {}, + background: {}, + mcp: {}, + defaultPlanMode: false, + mergeAllAvailableSkills: true, + extraSkillDirs: [], + subagent: { timeoutMs: 7_200_000 }, +}; + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((entry, index) => deepEqual(entry, b[index])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a); + return ( + aKeys.length === Object.keys(b).length && + aKeys.every((key) => key in b && deepEqual(a[key], b[key])) + ); + } + return false; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Projection for every method returning a `KimiConfig`: drop the fields whose + * presence semantics genuinely differ between the engines (see KNOWN_DIFFS). + */ +function projectConfig(config: KimiConfig): unknown { + const projected: Record = { ...config }; + delete projected['raw']; + for (const [field, defaultValue] of Object.entries(V2_INJECTED_SECTION_DEFAULTS)) { + if (deepEqual(projected[field], defaultValue)) delete projected[field]; + } + return projected; +} + +/** + * Both forms of a home dir: the path as handed to the harness and its + * realpath. Installed plugins land in a realpath'd managed copy under + * `/plugins/managed/`, and on macOS the temp dir itself is behind + * a symlink, so scrubbing needs both spellings. + */ +interface HomePair { + readonly raw: string; + readonly real: string; +} + +/** + * Replace every occurrence of a home prefix with `` so values holding + * absolute paths into a per-engine home compare across the isolated homes. + */ +function scrubHomePrefixes(value: unknown, home: HomePair): unknown { + let text = JSON.stringify(value); + // Longest first, so the more specific spelling wins when they overlap. + for (const prefix of [home.raw, home.real].toSorted((a, b) => b.length - a.length)) { + text = text.replaceAll(prefix, ''); + } + return JSON.parse(text); +} + +/** + * Understood v1↔v2 return-value gaps, pinned per method with the reason. + * Each entry is a projection applied to BOTH results before comparison, so + * the comparison still covers everything not listed here. Keep empty unless a + * gap is genuinely accepted; remove entries as gaps close. + */ +const KNOWN_DIFFS = { + // v2's flag registry is per-domain and already carries flags v1 does not + // have (fault-injection, minidb backend, subagent); v1-only flags would be + // the symmetric case. Parity is enforced on the intersection of ids until + // the registries are unified. + getExperimentalFeatures: ( + features: readonly { id: string }[], + other: readonly { id: string }[], + ): readonly { id: string }[] => { + const otherIds = new Set(other.map((feature) => feature.id)); + return features.filter((feature) => otherIds.has(feature.id)); + }, + // `raw`: the v1 write path carries a passthrough clone of the original + // TOML document inside the returned config; the v2 engine keeps the same + // document internally (rawSnake) but never exposes it through the config + // service. v2-injected section defaults: see V2_INJECTED_SECTION_DEFAULTS. + getConfig: (config: KimiConfig): unknown => projectConfig(config), + setConfig: (config: KimiConfig): unknown => projectConfig(config), + removeProvider: (config: KimiConfig): unknown => projectConfig(config), + // Both engines report a dropped invalid section, but the warning wording + // differs by design (v1: one salvage summary naming the dropped sections; + // v2: one structured diagnostic per dropped domain). Parity is enforced on + // the warning count; message text is compared only when both are empty. + getConfigDiagnostics: (diagnostics: ConfigDiagnostics): unknown => + diagnostics.warnings.length, + // `getPluginInfo` carries volatile install facts: `root` / `manifestPath` + // point into the per-home managed copy and `installedAt` / `updatedAt` are + // stamped at install time — per-home and per-run by construction, not a + // semantic difference. Everything else (manifest echo, MCP server info, + // counts, diagnostics) compares in full after the home-prefix scrub. + getPluginInfo: (info: PluginInfo, home: HomePair): unknown => { + const projected = scrubHomePrefixes(info, home) as Record; + delete projected['root']; + delete projected['installedAt']; + delete projected['updatedAt']; + delete projected['manifestPath']; + return projected; + }, + // Command `path`s point into the per-home managed copy; after the scrub + // the defs compare in full. Scope note: v1 answers from the session's + // creation-time snapshot of enabled commands, v2 from the app-global live + // view — parity holds here because the v1 session is created after the + // install (see the SDKRpcClientV2.listPluginCommands comment). + listPluginCommands: (defs: readonly PluginCommandDef[], home: HomePair): unknown => + scrubHomePrefixes(defs, home), + // Sessions: `createdAt` / `updatedAt` are per-run wall clock on both + // engines (v1 reads fs birth/mtime off the session directory, v2 reads the + // metadata stamps) — deleted, not compared. v1's store materializes the + // default title 'New Session' into state.json and reports it for + // never-titled sessions where v2 leaves the title unset; only that + // materialized default is projected away (explicit titles compare in + // full). Per-home paths (sessionDir, agent homedirs) compare after the + // home-prefix scrub — both engines lay sessions out as + // `/sessions//` with the same key derivation. + listSessions: (summaries: readonly SessionSummary[], home: HomePair): unknown => + summaries.map((summary) => projectSessionSummary(summary, home)), + createSession: (summary: SessionSummary, home: HomePair): unknown => + projectSessionSummary(summary, home), + // v1's fork returns the full resumed result (it resumes the fork); v2 + // mirrors that shape, so both project like a resume. + forkSession: (resumed: ResumedSessionSummary, home: HomePair): unknown => + projectResumedSession(resumed, home), + // Both engines resume the session and now return the per-agent snapshot; + // `agents` compares field-by-field after the projections documented on + // `projectResumedAgent` (bind-time config_updated replay records, + // config.provider / systemPrompt rendering, the tokenCount + // estimate-vs-measured divergence, per-run times and random ids, and the + // engine-owned tool roster/description drift). `sessionMetadata` compares + // on the overlapping document fields after the same timestamp/title + // projection. + resumeSession: (resumed: ResumedSessionSummary, home: HomePair): unknown => + projectResumedSession(resumed, home), + reloadSession: (resumed: ResumedSessionSummary, home: HomePair): unknown => + projectResumedSession(resumed, home), + // Agent context: v1 reports the running token ESTIMATE (an import adopts + // it wholesale); v2 reports the provider-MEASURED prefix, which an + // in-memory append never touches — post-import counts diverge by design + // (v1 > 0, v2 === 0, asserted explicitly at the call site). Histories + // compare in full; the count compares only in the pre-import state where + // both are 0. + getContext: (context: { readonly history: readonly unknown[] }): unknown => + context.history.length === 0 ? context : { history: context.history }, + // Plan ids are random per engine (hero slugs) and the plan path embeds + // both the per-home session dir and the id, so the comparison covers the + // content and the path LAYOUT (id scrubbed); the id itself is asserted + // non-empty at the call site. + getPlan: (plan: SessionPlan, home: HomePair): unknown => { + if (plan === null) return null; + const projected = scrubHomePrefixes({ content: plan.content, path: plan.path }, home) as { + content: string; + path: string; + }; + return { ...projected, path: projected.path.replaceAll(plan.id, '') }; + }, + // Goal snapshots: `goalId` is a per-engine random uuid and `wallClockMs` + // is per-run wall clock (it keeps accruing while the goal is active, so + // even a same-moment read differs by execution time) — both deleted; + // everything else (objective, status, counters, the full budget report, + // terminalReason) compares in full. Id non-emptiness is asserted at the + // call sites. + createGoal: (snapshot: GoalSnapshot | null): unknown => projectGoalSnapshot(snapshot), + getGoal: (result: GoalToolResult): unknown => projectGoalSnapshot(result.goal), + pauseGoal: (snapshot: GoalSnapshot | null): unknown => projectGoalSnapshot(snapshot), + resumeGoal: (snapshot: GoalSnapshot | null): unknown => projectGoalSnapshot(snapshot), + cancelGoal: (snapshot: GoalSnapshot | null): unknown => projectGoalSnapshot(snapshot), + // Background task infos: `taskId` embeds a per-engine random suffix and + // `pid` / `startedAt` / `endedAt` are per-run facts — deleted. `timeoutMs` + // diverges after a detach by design (v1 keeps the foreground deadline in + // the reported info; v2 rewrites it to the detach deadline — pinned in the + // migration tracker), so it is deleted too; the pre-detach equality is + // asserted explicitly at the call site. Everything else (kind, command, + // description, status, detached, exitCode, stopReason, flags) compares in + // full. + listBackgroundTasks: (tasks: readonly BackgroundTaskInfo[]): unknown => + tasks.map((task) => projectBackgroundTask(task)), + detachBackgroundTask: (info: BackgroundTaskInfo | undefined): unknown => + info === undefined ? undefined : projectBackgroundTask(info), + // MCP startup metrics: `durationMs` is per-run wall clock on both engines + // (connect settle time) — projected away and asserted numeric at the call + // site. The empty-config session reports exactly 0 on both (no connectAll + // ever runs) and compares in full there. + getMcpStartupMetrics: (metrics: McpStartupMetrics): unknown => + Object.keys(metrics).length === 1 ? {} : metrics, + // Session export: `zipPath` is the caller-chosen output (different per + // engine by construction) — deleted. `sessionDir` compares after the + // home-prefix scrub (same `/sessions//` layout on + // both). In the manifest: `exportedAt` is per-run wall clock; + // `wireProtocolVersion` is each engine's own wire version (v1 '1.4' vs v2 + // '1.5' — genuinely different formats); the activity timestamps only exist + // on v2 because v1's scanner reads a stale root `wire.jsonl` path (v1 + // keeps its wire under `agents/main/`, so v1 never reports them — a v1-side + // defect, not a v2 divergence); and v1's store materializes the default + // title 'New Session' where v2 leaves it unset (same rule as listSessions). + exportSession: (result: ExportSessionResult, home: HomePair): unknown => { + const projected = scrubHomePrefixes(result, home) as Record; + delete projected['zipPath']; + const manifest = projected['manifest'] as Record; + delete manifest['exportedAt']; + delete manifest['wireProtocolVersion']; + delete manifest['sessionFirstActivity']; + delete manifest['sessionLastActivity']; + if (manifest['title'] === 'New Session') delete manifest['title']; + return projected; + }, + // Session skills: `path`s point into each engine's own home (user skills) + // or the shared packages (builtins) — after the home-prefix scrub the + // summaries compare in full. + listSkills: (skills: readonly SkillSummary[], home: HomePair): unknown => + scrubHomePrefixes(skills, home), +} satisfies Record unknown>; + +/** See the KNOWN_DIFFS goal note above for what this projects and why. */ +function projectGoalSnapshot(snapshot: GoalSnapshot | null): unknown { + if (snapshot === null) return null; + const projected: Record = { ...snapshot }; + delete projected['goalId']; + delete projected['wallClockMs']; + return projected; +} + +/** See the KNOWN_DIFFS background-task note above for what this projects and why. */ +function projectBackgroundTask(info: BackgroundTaskInfo): unknown { + const projected: Record = { ...info }; + delete projected['taskId']; + delete projected['pid']; + delete projected['startedAt']; + delete projected['endedAt']; + delete projected['timeoutMs']; + return projected; +} + +/** See the KNOWN_DIFFS sessions note above for what this projects and why. */ +function projectSessionSummary(summary: SessionSummary, home: HomePair): unknown { + const projected = scrubHomePrefixes(summary, home) as Record; + delete projected['createdAt']; + delete projected['updatedAt']; + if (projected['title'] === 'New Session') delete projected['title']; + return projected; +} + +/** See the KNOWN_DIFFS resume/reload note above for what this projects and why. */ +function projectResumedSession(resumed: ResumedSessionSummary, home: HomePair): unknown { + const projected = projectSessionSummary(resumed, home) as Record; + projected['agents'] = projectResumedAgents(resumed.agents, home); + const metadata = projected['sessionMetadata'] as Record; + delete metadata['createdAt']; + delete metadata['updatedAt']; + // v1 defaults an untitled metadata document to 'New Session'; the v2 + // mapping reports the same unset title as ''. + if (metadata['title'] === 'New Session' || metadata['title'] === '') { + delete metadata['title']; + } + return projected; +} + +function projectResumedAgents( + agents: ResumedSessionSummary['agents'], + home: HomePair, +): unknown { + const projected: Record = {}; + for (const [agentId, agent] of Object.entries(agents)) { + projected[agentId] = projectResumedAgent(agent, home); + } + return projected; +} + +/** + * The per-agent projections of the resume comparison, each a genuinely + * accepted engine gap rather than resume data: + * - `config.provider`: v1 resolves the full runtime ProviderConfig into the + * snapshot; agent-core-v2 has no equivalent read and the v2 client always + * reports undefined (the TUI only falls back to `provider?.model` when + * `modelAlias` is unset). + * - `config.systemPrompt`: bootstrap-rendered profile content (engine-owned), + * not resume data — it embeds the host's skill listing, whose truncation + * the two engines render differently (`…` vs `...`). + * - `config.profileName` on a MODEL-LESS home: v1 + * still binds the default profile (and renders its prompt) without a + * model, where v2's bind requires a model and deliberately leaves the + * agent unbound (the same model-less gap the getStatus parity pins). With + * a configured model both bind the same profile and compare in full. + * - `context.tokenCount`: the KNOWN_DIFFS.getContext divergence (v1's running + * estimate vs v2's provider-measured prefix) — the history compares in + * full, the count only in the empty state. + * - replay `config_updated` records: v1 persists the profile BIND as + * `config.update` records (which restore maps to config_updated entries); + * v2 persists it as a `profile.bind` op the replay fold deliberately does + * not translate. Runtime config changes (`config.update`) still fold and + * compare — only the bind-time entries exist in these fixtures, so the + * type is dropped from both sides. + * - replay `time`: per-run wall clock. A goal snapshot's `goalId` / + * `wallClockMs`: the KNOWN_DIFFS goal rule (per-engine random uuid / + * accruing clock). + * - `plan`: the KNOWN_DIFFS.getPlan rule (random hero-slug id, scrubbed out + * of the path after the home scrub). + * - `tools`: compared as sorted {name, active, source} triples. Tool + * DESCRIPTIONS are engine-owned constants that legitimately drift between + * the engines (the subagent/cron docs embed engine-specific facts), and + * v1 additionally registers the `select_tools` meta tool v2 has no + * counterpart for — both are engine design, not resume data. A model-less + * agent's roster is not compared at all (v1 initializes builtin tools + * only on a profiled agent; v2 exposes them unbound). + */ +function projectResumedAgent(agent: ResumedAgentState, home: HomePair): unknown { + const projected = scrubHomePrefixes(agent, home) as Record; + const config = projected['config'] as Record; + delete config['provider']; + delete config['systemPrompt']; + const modelLess = config['modelAlias'] === undefined; + if (modelLess) { + delete config['profileName']; + projected['tools'] = []; + } else { + const tools = projected['tools'] as readonly Record[]; + projected['tools'] = tools + .filter((tool) => tool['name'] !== 'select_tools') + .map((tool) => ({ name: tool['name'], active: tool['active'], source: tool['source'] })) + .toSorted((a, b) => String(a.name).localeCompare(String(b.name))); + } + const context = projected['context'] as { readonly history: readonly unknown[] }; + projected['context'] = + context.history.length === 0 ? context : { history: context.history }; + const replay = (projected['replay'] as readonly Record[]) + .filter((record) => record['type'] !== 'config_updated') + .map((record) => { + const { time: _time, ...rest } = record; + if (rest['type'] === 'goal_updated') { + rest['snapshot'] = projectGoalSnapshot(rest['snapshot'] as GoalSnapshot); + } + return rest; + }); + projected['replay'] = replay; + const plan = projected['plan'] as { id: string; content: string; path: string } | null; + if (plan !== null) { + projected['plan'] = { + content: plan.content, + path: plan.path.replaceAll(plan.id, ''), + }; + } + return projected; +} + +/** Cross the same JSON boundary both engines' values already crossed, then + * sort object arrays by `key` so ordering differences don't count. */ +function normalize(value: unknown, key: string): unknown { + const roundTripped: unknown = JSON.parse(JSON.stringify(value)); + if (Array.isArray(roundTripped)) { + return roundTripped.toSorted((a, b) => + JSON.stringify(a[key] ?? a).localeCompare(JSON.stringify(b[key] ?? b)), + ); + } + return roundTripped; +} + +interface ParityFixture { + readonly v1: KimiHarness; + readonly v2: KimiHarness; + readonly homeDir: string; +} + +async function makeParityPair(): Promise { + const homeDir = await makeTempDir('kimi-sdk-parity-home-'); + const v1 = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + const v2 = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + return { v1, v2, homeDir }; +} + +async function closeAll(...harnesses: readonly KimiHarness[]): Promise { + for (const harness of harnesses) { + await harness.close(); + } +} + +/** + * Config-reading env vars both engines honor (`KIMI_MODEL_*`, the per-section + * bindings, ...). Config parity cases scrub them so a developer machine's own + * environment cannot inject extra providers/models/overrides into one engine + * and skew the comparison; the original values are restored on cleanup. + */ +const CONFIG_ENV_PATTERN = + /^(KIMI_MODEL_|KIMI_LOOP_|KIMI_MCP_|KIMI_WEB_|KIMI_SECONDARY_|KIMI_IMAGE_|KIMI_CODE_BACKGROUND_|KIMI_CODE_MODEL_CATALOG_)/; + +function scrubConfigEnv(): () => void { + const saved: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && CONFIG_ENV_PATTERN.test(key)) { + saved[key] = value; + delete process.env[key]; + } + } + return () => { + Object.assign(process.env, saved); + }; +} + +/** + * Config parity uses SEPARATE homes (the batch rule: a write through one + * engine must not be observed by the other mid-test) with the same fixture + * config.toml written into each before the harnesses are constructed. + */ +async function makeIsolatedParityPair( + configToml?: string, +): Promise<{ v1: KimiHarness; v2: KimiHarness }> { + const v1Home = await makeTempDir('kimi-sdk-parity-v1-home-'); + const v2Home = await makeTempDir('kimi-sdk-parity-v2-home-'); + if (configToml !== undefined) { + await writeFile(join(v1Home, 'config.toml'), configToml, 'utf-8'); + await writeFile(join(v2Home, 'config.toml'), configToml, 'utf-8'); + } + return { + v1: createKimiHarness({ homeDir: v1Home, identity: TEST_IDENTITY }), + v2: createKimiHarnessV2({ homeDir: v2Home, identity: TEST_IDENTITY }), + }; +} + +/** + * Representative config.toml covering every section the v1↔v2 field mapping + * shares: providers/models with default pointers, thinking, loop_control, + * background, subagent, mcp, image, experimental, permission rules, hooks, + * services, and the pass-through scalars. `[model_catalog]` is deliberately + * absent: v1's TOML transform drops that section (plain-object key without a + * transform branch), so v1 never returns it while v2 does — pinned in the + * migration tracker, not exercised here. + */ +const FIXTURE_CONFIG_TOML = ` +default_provider = "fixture-provider" +default_model = "fixture-model" +default_permission_mode = "auto" +plan_mode = false +yolo = false +telemetry = false +merge_all_available_skills = false +extra_skill_dirs = ["/tmp/parity-extra-skills"] + +[providers.fixture-provider] +type = "kimi" +api_key = "fixture-api-key" +base_url = "https://example.com/v1" + +[providers.second-provider] +type = "openai" +api_key = "second-api-key" + +[models.fixture-model] +provider = "fixture-provider" +model = "kimi-for-coding" +max_context_size = 262144 +capabilities = ["thinking"] + +[models.second-model] +provider = "second-provider" +model = "second-brain" +max_context_size = 128000 + +[thinking] +enabled = true +effort = "high" + +[loop_control] +max_steps_per_turn = 50 + +[background] +max_running_tasks = 4 + +[subagent] +timeout_ms = 60000 + +[mcp] +startup_timeout_ms = 45000 + +[image] +max_edge_px = 1024 + +[experimental] +parity-flag = true + +[permission] +deny = [{ tool = "Bash" }] + +[[hooks]] +event = "SessionStart" +command = "echo parity" + +[services.moonshot_search] +base_url = "https://example.com/search" +api_key = "search-api-key" +`; + +/** One schema-invalid section (thinking.enabled must be a boolean). */ +const BROKEN_CONFIG_TOML = ` +[providers.fixture-provider] +type = "kimi" +api_key = "fixture-api-key" + +[thinking] +enabled = "not-a-boolean" +`; + +function expectConfigParity(v1Config: KimiConfig, v2Config: KimiConfig): void { + const project = KNOWN_DIFFS.getConfig; + expect(normalize(project(v2Config), '')).toEqual(normalize(project(v1Config), '')); +} + +describe('v1↔v2 return-value parity', () => { + it('getExperimentalFeatures matches on the shared flag ids', async () => { + const { v1, v2 } = await makeParityPair(); + try { + const [v1Features, v2Features] = await Promise.all([ + v1.getExperimentalFeatures(), + v2.getExperimentalFeatures(), + ]); + const project = KNOWN_DIFFS.getExperimentalFeatures; + expect(normalize(project(v2Features, v1Features), 'id')).toEqual( + normalize(project(v1Features, v2Features), 'id'), + ); + } finally { + await closeAll(v1, v2); + } + }); + + it('listWorkspaceSkills returns the same skills on the same fixture', async () => { + const { v1, v2, homeDir } = await makeParityPair(); + const workDir = await makeTempDir('kimi-sdk-parity-work-'); + await writeSkill(join(homeDir, 'skills', 'parity-user-skill'), 'parity-user-skill'); + await writeSkill( + join(workDir, '.kimi-code', 'skills', 'parity-project-skill'), + 'parity-project-skill', + ); + try { + const [v1Skills, v2Skills] = await Promise.all([ + v1.listWorkspaceSkills(workDir), + v2.listWorkspaceSkills(workDir), + ]); + expect(normalize(v2Skills, 'name')).toEqual(normalize(v1Skills, 'name')); + } finally { + await closeAll(v1, v2); + } + }); + + it('getConfig matches on an empty home (no config.toml)', async () => { + const restoreEnv = scrubConfigEnv(); + const { v1, v2 } = await makeIsolatedParityPair(); + try { + const [v1Config, v2Config] = await Promise.all([v1.getConfig(), v2.getConfig()]); + expectConfigParity(v1Config, v2Config); + } finally { + await closeAll(v1, v2); + restoreEnv(); + } + }); + + it('getConfig matches on a representative config.toml, with and without reload', async () => { + const restoreEnv = scrubConfigEnv(); + const { v1, v2 } = await makeIsolatedParityPair(FIXTURE_CONFIG_TOML); + try { + const [v1Config, v2Config] = await Promise.all([v1.getConfig(), v2.getConfig()]); + expectConfigParity(v1Config, v2Config); + const [v1Reloaded, v2Reloaded] = await Promise.all([ + v1.getConfig({ reload: true }), + v2.getConfig({ reload: true }), + ]); + expectConfigParity(v1Reloaded, v2Reloaded); + } finally { + await closeAll(v1, v2); + restoreEnv(); + } + }); + + it('setConfig returns the same merged config and reads back identically', async () => { + const restoreEnv = scrubConfigEnv(); + const { v1, v2 } = await makeIsolatedParityPair(FIXTURE_CONFIG_TOML); + try { + const patch = { + defaultModel: 'second-model', + thinking: { effort: 'low' }, + experimental: { 'new-flag': false }, + yolo: true, + }; + const [v1Config, v2Config] = await Promise.all([ + v1.setConfig(patch), + v2.setConfig(patch), + ]); + const project = KNOWN_DIFFS.setConfig; + expect(normalize(project(v2Config), '')).toEqual(normalize(project(v1Config), '')); + // The write also persisted: a fresh read through each engine agrees. + const [v1ReadBack, v2ReadBack] = await Promise.all([v1.getConfig(), v2.getConfig()]); + expectConfigParity(v1ReadBack, v2ReadBack); + } finally { + await closeAll(v1, v2); + restoreEnv(); + } + }); + + it('removeProvider cascades to models and default pointers identically', async () => { + const restoreEnv = scrubConfigEnv(); + const { v1, v2 } = await makeIsolatedParityPair(FIXTURE_CONFIG_TOML); + try { + const [v1Config, v2Config] = await Promise.all([ + v1.removeProvider('fixture-provider'), + v2.removeProvider('fixture-provider'), + ]); + const project = KNOWN_DIFFS.removeProvider; + expect(normalize(project(v2Config), '')).toEqual(normalize(project(v1Config), '')); + // v1 semantics: the provider entry, every model whose `provider` points + // at it, and both default pointers (the default model was one of the + // removed models) are gone. + for (const config of [v1Config, v2Config]) { + expect(Object.keys(config.providers)).toEqual(['second-provider']); + expect(config.models !== undefined && 'fixture-model' in config.models).toBe(false); + expect(config.defaultModel).toBeUndefined(); + expect(config.defaultProvider).toBeUndefined(); + } + const [v1ReadBack, v2ReadBack] = await Promise.all([v1.getConfig(), v2.getConfig()]); + expectConfigParity(v1ReadBack, v2ReadBack); + } finally { + await closeAll(v1, v2); + restoreEnv(); + } + }); + + it('getConfigDiagnostics is empty on a valid config', async () => { + const restoreEnv = scrubConfigEnv(); + const { v1, v2 } = await makeIsolatedParityPair(FIXTURE_CONFIG_TOML); + try { + const [v1Diagnostics, v2Diagnostics] = await Promise.all([ + v1.getConfigDiagnostics(), + v2.getConfigDiagnostics(), + ]); + expect(normalize(v2Diagnostics, '')).toEqual(normalize(v1Diagnostics, '')); + expect(v1Diagnostics.warnings).toEqual([]); + } finally { + await closeAll(v1, v2); + restoreEnv(); + } + }); + + it('getConfigDiagnostics reports a dropped invalid section on both engines', async () => { + const restoreEnv = scrubConfigEnv(); + const { v1, v2 } = await makeIsolatedParityPair(BROKEN_CONFIG_TOML); + try { + const [v1Diagnostics, v2Diagnostics] = await Promise.all([ + v1.getConfigDiagnostics(), + v2.getConfigDiagnostics(), + ]); + const project = KNOWN_DIFFS.getConfigDiagnostics; + expect(project(v2Diagnostics)).toEqual(project(v1Diagnostics)); + expect(v1Diagnostics.warnings.length).toBeGreaterThan(0); + // The valid sections survive the drop on both engines. + const [v1Config, v2Config] = await Promise.all([v1.getConfig(), v2.getConfig()]); + expectConfigParity(v1Config, v2Config); + } finally { + await closeAll(v1, v2); + restoreEnv(); + } + }); +}); + +async function writeSkill(dir: string, name: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: Skill ${name} for the parity test\n---\n\nBody of ${name}.\n`, + 'utf-8', + ); +} + +// --------------------------------------------------------------------------- +// Plugin parity +// +// The plugin methods live on the rpc client itself (KimiHarness has no +// plugin surface), so plugin parity drives `SDKRpcClient` / `SDKRpcClientV2` +// directly. Homes stay isolated (same rule as config parity), and both +// engines install from ONE shared local source directory — zip-url / github +// install forms need the network and are deliberately out of scope (pinned +// in the migration tracker). +// --------------------------------------------------------------------------- + +interface PluginParityPair { + readonly v1: SDKRpcClient; + readonly v2: SDKRpcClientV2; + readonly v1Home: HomePair; + readonly v2Home: HomePair; + readonly sourceDir: string; +} + +const FIXTURE_PLUGIN_ID = 'parity-plugin'; + +/** + * One fixture plugin exercising every shared PluginSummary/PluginInfo field: + * one discoverable skill, two MCP servers (stdio + http), a hook, a command + * file, a session-start entry, and the interface block. + */ +async function writeFixturePlugin(dir: string): Promise { + await mkdir(join(dir, 'skills', 'parity-skill'), { recursive: true }); + await mkdir(join(dir, 'commands'), { recursive: true }); + await writeFile( + join(dir, 'kimi.plugin.json'), + JSON.stringify( + { + name: FIXTURE_PLUGIN_ID, + version: '1.2.3', + description: 'Plugin for the v1↔v2 parity test', + keywords: ['parity', 'test'], + author: { name: 'Parity Author', email: 'parity@example.com' }, + homepage: 'https://example.com/parity-plugin', + license: 'MIT', + skills: ['./skills'], + sessionStart: { skill: 'parity-skill' }, + mcpServers: { + 'parity-stdio': { + transport: 'stdio', + command: 'parity-server', + args: ['--serve', '--verbose'], + env: { PARITY_TOKEN: 'fixture', PARITY_MODE: 'test' }, + }, + 'parity-http': { + transport: 'http', + url: 'https://example.com/mcp', + headers: { 'X-Parity': '1' }, + }, + }, + hooks: [{ event: 'SessionStart', command: 'echo parity' }], + commands: './commands', + interface: { + displayName: 'Parity Plugin', + shortDescription: 'Parity fixture', + developerName: 'Parity Author', + websiteURL: 'https://example.com/parity-plugin', + }, + }, + null, + 2, + ), + 'utf-8', + ); + await writeFile( + join(dir, 'skills', 'parity-skill', 'SKILL.md'), + '---\nname: parity-skill\ndescription: Skill from the parity fixture plugin\n---\n\nParity skill body.\n', + 'utf-8', + ); + await writeFile( + join(dir, 'commands', 'parity-command.md'), + '---\nname: parity-command\ndescription: Run the parity command\n---\n\nBody of the parity command.\n', + 'utf-8', + ); +} + +async function makePluginParityPair(): Promise { + const v1HomeDir = await makeTempDir('kimi-sdk-parity-v1-home-'); + const v2HomeDir = await makeTempDir('kimi-sdk-parity-v2-home-'); + const sourceDir = await makeTempDir('kimi-sdk-parity-plugin-src-'); + await writeFixturePlugin(sourceDir); + return { + v1: new SDKRpcClient({ homeDir: v1HomeDir, identity: TEST_IDENTITY }), + v2: new SDKRpcClientV2({ homeDir: v2HomeDir, identity: TEST_IDENTITY }), + v1Home: { raw: v1HomeDir, real: await realpath(v1HomeDir) }, + v2Home: { raw: v2HomeDir, real: await realpath(v2HomeDir) }, + sourceDir, + }; +} + +async function closePluginPair(pair: PluginParityPair): Promise { + await pair.v1.close(); + await pair.v2.close(); +} + +function installFixtureOnBoth( + pair: PluginParityPair, +): Promise<[PluginSummary, PluginSummary]> { + return Promise.all([ + pair.v1.installPlugin(pair.sourceDir), + pair.v2.installPlugin(pair.sourceDir), + ]); +} + +describe('v1↔v2 plugin parity', () => { + it('installPlugin from a local directory returns the same summary', async () => { + const pair = await makePluginParityPair(); + try { + const [v1Summary, v2Summary] = await installFixtureOnBoth(pair); + expect(normalize(v2Summary, 'id')).toEqual(normalize(v1Summary, 'id')); + expect(v1Summary).toMatchObject({ + id: FIXTURE_PLUGIN_ID, + displayName: 'Parity Plugin', + version: '1.2.3', + enabled: true, + state: 'ok', + skillCount: 1, + mcpServerCount: 2, + enabledMcpServerCount: 2, + hookCount: 1, + commandCount: 1, + hasErrors: false, + source: 'local-path', + }); + } finally { + await closePluginPair(pair); + } + }); + + it('listPlugins returns the same summaries after the same install', async () => { + const pair = await makePluginParityPair(); + try { + await installFixtureOnBoth(pair); + const [v1Plugins, v2Plugins] = await Promise.all([ + pair.v1.listPlugins(), + pair.v2.listPlugins(), + ]); + expect(normalize(v2Plugins, 'id')).toEqual(normalize(v1Plugins, 'id')); + expect(v1Plugins).toHaveLength(1); + } finally { + await closePluginPair(pair); + } + }); + + it('getPluginInfo returns the same info modulo per-home paths and install stamps', async () => { + const pair = await makePluginParityPair(); + try { + await installFixtureOnBoth(pair); + const [v1Info, v2Info] = await Promise.all([ + pair.v1.getPluginInfo(FIXTURE_PLUGIN_ID), + pair.v2.getPluginInfo(FIXTURE_PLUGIN_ID), + ]); + const project = KNOWN_DIFFS.getPluginInfo; + expect(project(v2Info, pair.v2Home)).toEqual(project(v1Info, pair.v1Home)); + expect(v1Info.mcpServers.map((server) => server.name)).toEqual([ + 'parity-http', + 'parity-stdio', + ]); + } finally { + await closePluginPair(pair); + } + }); + + it('setPluginEnabled toggles identically on both engines', async () => { + const pair = await makePluginParityPair(); + try { + await installFixtureOnBoth(pair); + await Promise.all([ + pair.v1.setPluginEnabled(FIXTURE_PLUGIN_ID, false), + pair.v2.setPluginEnabled(FIXTURE_PLUGIN_ID, false), + ]); + const [v1Disabled, v2Disabled] = await Promise.all([ + pair.v1.listPlugins(), + pair.v2.listPlugins(), + ]); + expect(normalize(v2Disabled, 'id')).toEqual(normalize(v1Disabled, 'id')); + expect(v1Disabled[0]?.enabled).toBe(false); + await Promise.all([ + pair.v1.setPluginEnabled(FIXTURE_PLUGIN_ID, true), + pair.v2.setPluginEnabled(FIXTURE_PLUGIN_ID, true), + ]); + const [v1Enabled, v2Enabled] = await Promise.all([ + pair.v1.listPlugins(), + pair.v2.listPlugins(), + ]); + expect(normalize(v2Enabled, 'id')).toEqual(normalize(v1Enabled, 'id')); + expect(v1Enabled[0]?.enabled).toBe(true); + } finally { + await closePluginPair(pair); + } + }); + + it('setPluginMcpServerEnabled toggles one server identically on both engines', async () => { + const pair = await makePluginParityPair(); + try { + await installFixtureOnBoth(pair); + await Promise.all([ + pair.v1.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false), + pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false), + ]); + const [v1Info, v2Info] = await Promise.all([ + pair.v1.getPluginInfo(FIXTURE_PLUGIN_ID), + pair.v2.getPluginInfo(FIXTURE_PLUGIN_ID), + ]); + const project = KNOWN_DIFFS.getPluginInfo; + expect(project(v2Info, pair.v2Home)).toEqual(project(v1Info, pair.v1Home)); + expect(v1Info.enabledMcpServerCount).toBe(1); + expect(v1Info.mcpServers.find((server) => server.name === 'parity-stdio')?.enabled).toBe( + false, + ); + } finally { + await closePluginPair(pair); + } + }); + + it('removePlugin leaves both engines with an empty catalog', async () => { + const pair = await makePluginParityPair(); + try { + await installFixtureOnBoth(pair); + await Promise.all([ + pair.v1.removePlugin(FIXTURE_PLUGIN_ID), + pair.v2.removePlugin(FIXTURE_PLUGIN_ID), + ]); + const [v1Plugins, v2Plugins] = await Promise.all([ + pair.v1.listPlugins(), + pair.v2.listPlugins(), + ]); + expect(v2Plugins).toEqual(v1Plugins); + expect(v1Plugins).toEqual([]); + } finally { + await closePluginPair(pair); + } + }); + + it('reloadPlugins reports no catalog change and re-materializes disk edits identically', async () => { + const pair = await makePluginParityPair(); + try { + await installFixtureOnBoth(pair); + const [v1Reload, v2Reload] = await Promise.all([ + pair.v1.reloadPlugins(), + pair.v2.reloadPlugins(), + ]); + expect(v2Reload).toEqual(v1Reload); + expect(v1Reload).toEqual({ added: [], removed: [], errors: [] }); + // An out-of-band edit to the managed copy is picked up by the next + // reload on both engines (reload re-reads the manifest from disk). + for (const home of [pair.v1Home, pair.v2Home]) { + const manifestPath = join( + home.real, + 'plugins', + 'managed', + FIXTURE_PLUGIN_ID, + 'kimi.plugin.json', + ); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version: string }; + manifest.version = '2.0.0'; + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + } + const [v1Reloaded, v2Reloaded] = await Promise.all([ + pair.v1.reloadPlugins(), + pair.v2.reloadPlugins(), + ]); + expect(v2Reloaded).toEqual(v1Reloaded); + const [v1Plugins, v2Plugins] = await Promise.all([ + pair.v1.listPlugins(), + pair.v2.listPlugins(), + ]); + expect(normalize(v2Plugins, 'id')).toEqual(normalize(v1Plugins, 'id')); + expect(v1Plugins[0]?.version).toBe('2.0.0'); + } finally { + await closePluginPair(pair); + } + }); + + it('listPluginCommands returns the same enabled commands', async () => { + const pair = await makePluginParityPair(); + const workDir = await makeTempDir('kimi-sdk-parity-work-'); + try { + await installFixtureOnBoth(pair); + // The v1 session connects the plugin's enabled MCP servers at creation; + // disable them first so the test stays offline (the fixture's http + // server URL is a real-looking host). Commands are unaffected. v1's + // plugin manager persists without a mutation queue (v2 serializes + // internally), so same-engine mutations stay sequential here. + await Promise.all([ + (async () => { + await pair.v1.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false); + await pair.v1.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-http', false); + })(), + (async () => { + await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false); + await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-http', false); + })(), + ]); + // v1 answers from the session's creation-time snapshot, so the session + // must be created after the install; v2 ignores the sessionId (app-global + // live view). + const session = await pair.v1.createSession({ workDir }); + try { + const [v1Commands, v2Commands] = await Promise.all([ + pair.v1.listPluginCommands({ sessionId: session.id }), + pair.v2.listPluginCommands({ sessionId: session.id }), + ]); + const project = KNOWN_DIFFS.listPluginCommands; + expect(normalize(project(v2Commands, pair.v2Home), 'name')).toEqual( + normalize(project(v1Commands, pair.v1Home), 'name'), + ); + expect(v1Commands).toHaveLength(1); + expect(v1Commands[0]).toMatchObject({ + pluginId: FIXTURE_PLUGIN_ID, + name: 'parity-command', + description: 'Run the parity command', + body: 'Body of the parity command.', + }); + } finally { + await pair.v1.closeSession({ sessionId: session.id }); + } + } finally { + await closePluginPair(pair); + } + }); +}); + +// --------------------------------------------------------------------------- +// Session lifecycle parity +// +// The session methods are driven through `SDKRpcClient` / `SDKRpcClientV2` +// directly (same rule as the plugin parity): isolated per-engine homes, one +// SHARED workDir (the sessions live under each home; the workDir is only a +// referenced path, so sharing it makes the workDir / additionalDirs / +// configPath comparisons exact). Explicit session ids keep identity +// comparisons meaningful. No provider calls anywhere — create / resume / +// reload / fork never touch a model. `deleteSession` has no parity case: +// the v2 engine has no session-deletion capability, so the v2 side stays +// not_implemented by design (tracked in `.tmp/v2-migration-tracker.md`). +// --------------------------------------------------------------------------- + +interface SessionParityPair { + readonly v1: SDKRpcClient; + readonly v2: SDKRpcClientV2; + readonly v1Home: HomePair; + readonly v2Home: HomePair; + readonly workDir: string; +} + +async function makeSessionParityPair(): Promise { + const v1HomeDir = await makeTempDir('kimi-sdk-parity-v1-home-'); + const v2HomeDir = await makeTempDir('kimi-sdk-parity-v2-home-'); + const workDir = await makeTempDir('kimi-sdk-parity-work-'); + return { + v1: new SDKRpcClient({ homeDir: v1HomeDir, identity: TEST_IDENTITY }), + v2: new SDKRpcClientV2({ homeDir: v2HomeDir, identity: TEST_IDENTITY }), + v1Home: { raw: v1HomeDir, real: await realpath(v1HomeDir) }, + v2Home: { raw: v2HomeDir, real: await realpath(v2HomeDir) }, + workDir, + }; +} + +async function closeSessionPair(pair: SessionParityPair): Promise { + await pair.v1.close(); + await pair.v2.close(); +} + +/** + * Give asynchronously-failing turns (model-less prompt/steer/activation + * launches) a moment to settle before the harnesses close — the failures are + * engine-internal and not part of the compared surface, but closing + * mid-failure is a data race the test does not need. + */ +async function settleTurns(): Promise { + await new Promise((resolve) => setTimeout(resolve, 500)); +} + +async function createOnBoth( + pair: SessionParityPair, + input: { readonly id: string; readonly metadata?: JsonObject }, +): Promise { + await Promise.all([ + pair.v1.createSession({ workDir: pair.workDir, ...input }), + pair.v2.createSession({ workDir: pair.workDir, ...input }), + ]); +} + +/** v2 materializes the main agent lazily (on the cold-resume path) where v1 + * creates it eagerly; the fork copies the source's agent roster, so close + + * cold-resume the source on both engines first to give the fork the same + * roster to copy. */ +async function materializeMainAgentOnBoth(pair: SessionParityPair, id: string): Promise { + await Promise.all([pair.v1.closeSession({ sessionId: id }), pair.v2.closeSession({ sessionId: id })]); + await Promise.all([pair.v1.resumeSession({ id }), pair.v2.resumeSession({ id })]); +} + +/** + * Append one raw record to the main agent's `wire.jsonl` under the given + * home (both engines lay sessions out as + * `/sessions///agents/main/wire.jsonl`). Lets a parity + * fixture feed the SAME wire record through both engines' restore paths. + */ +async function appendMainWireRecord( + home: HomePair, + sessionId: string, + record: JsonObject, +): Promise { + const sessionsRoot = join(home.raw, 'sessions'); + for (const bucket of await readdir(sessionsRoot)) { + const wirePath = join(sessionsRoot, bucket, sessionId, 'agents', 'main', 'wire.jsonl'); + try { + await appendFile(wirePath, JSON.stringify(record) + '\n', 'utf-8'); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + throw new Error(`wire.jsonl for ${sessionId} not found under ${sessionsRoot}`); +} + +describe('v1↔v2 session lifecycle parity', () => { + it('createSession returns the same summary for the same input', async () => { + const pair = await makeSessionParityPair(); + const extraDir = await makeTempDir('kimi-sdk-parity-extra-'); + try { + const input = { + id: 'session_parity_create', + workDir: pair.workDir, + metadata: { origin: 'parity', nested: { n: 1 } }, + additionalDirs: [extraDir], + } as const; + const [v1Summary, v2Summary] = await Promise.all([ + pair.v1.createSession(input), + pair.v2.createSession(input), + ]); + const project = KNOWN_DIFFS.createSession; + expect(project(v2Summary, pair.v2Home)).toEqual(project(v1Summary, pair.v1Home)); + expect(v1Summary.additionalDirs).toEqual([extraDir]); + expect(v1Summary.metadata).toEqual({ origin: 'parity', nested: { n: 1 } }); + for (const summary of [v1Summary, v2Summary]) { + expect(typeof summary.createdAt).toBe('number'); + expect(typeof summary.updatedAt).toBe('number'); + } + } finally { + await closeSessionPair(pair); + } + }); + + it('createSession rejects a duplicate id on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_duplicate' }); + await expect( + pair.v1.createSession({ id: 'session_parity_duplicate', workDir: pair.workDir }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_ALREADY_EXISTS }); + await expect( + pair.v2.createSession({ id: 'session_parity_duplicate', workDir: pair.workDir }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_ALREADY_EXISTS }); + } finally { + await closeSessionPair(pair); + } + }); + + it('listSessions reflects the same creates and renames across filters', async () => { + const pair = await makeSessionParityPair(); + const otherWorkDir = await makeTempDir('kimi-sdk-parity-work-other-'); + try { + await createOnBoth(pair, { id: 'session_parity_one', metadata: { tag: 'one' } }); + await Promise.all([ + pair.v1.createSession({ id: 'session_parity_two', workDir: otherWorkDir }), + pair.v2.createSession({ id: 'session_parity_two', workDir: otherWorkDir }), + ]); + await Promise.all([ + pair.v1.renameSession({ id: 'session_parity_one', title: 'Parity Session One' }), + pair.v2.renameSession({ id: 'session_parity_one', title: 'Parity Session One' }), + ]); + const project = KNOWN_DIFFS.listSessions; + const [v1All, v2All] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2All, pair.v2Home), 'id')).toEqual( + normalize(project(v1All, pair.v1Home), 'id'), + ); + expect(v1All).toHaveLength(2); + const [v1Filtered, v2Filtered] = await Promise.all([ + pair.v1.listSessions({ workDir: pair.workDir }), + pair.v2.listSessions({ workDir: pair.workDir }), + ]); + expect(normalize(project(v2Filtered, pair.v2Home), 'id')).toEqual( + normalize(project(v1Filtered, pair.v1Home), 'id'), + ); + expect(v1Filtered.map((summary) => summary.id)).toEqual(['session_parity_one']); + const [v1ById, v2ById] = await Promise.all([ + pair.v1.listSessions({ sessionId: 'session_parity_two' }), + pair.v2.listSessions({ sessionId: 'session_parity_two' }), + ]); + expect(normalize(project(v2ById, pair.v2Home), 'id')).toEqual( + normalize(project(v1ById, pair.v1Home), 'id'), + ); + expect(v1ById.map((summary) => summary.id)).toEqual(['session_parity_two']); + } finally { + await closeSessionPair(pair); + } + }); + + it('renameSession renames a closed session and rejects an empty title on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_rename' }); + await Promise.all([ + pair.v1.closeSession({ sessionId: 'session_parity_rename' }), + pair.v2.closeSession({ sessionId: 'session_parity_rename' }), + ]); + await Promise.all([ + pair.v1.renameSession({ id: 'session_parity_rename', title: 'Renamed After Close' }), + pair.v2.renameSession({ id: 'session_parity_rename', title: 'Renamed After Close' }), + ]); + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + const project = KNOWN_DIFFS.listSessions; + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + // An explicit title compares in full (no projection involved). + expect(v1List[0]?.title).toBe('Renamed After Close'); + expect(v2List[0]?.title).toBe('Renamed After Close'); + await expect( + pair.v1.renameSession({ id: 'session_parity_rename', title: ' ' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_TITLE_EMPTY }); + await expect( + pair.v2.renameSession({ id: 'session_parity_rename', title: ' ' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_TITLE_EMPTY }); + } finally { + await closeSessionPair(pair); + } + }); + + it('closeSession keeps the session listed and is idempotent', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_close' }); + await Promise.all([ + pair.v1.closeSession({ sessionId: 'session_parity_close' }), + pair.v2.closeSession({ sessionId: 'session_parity_close' }), + ]); + const project = KNOWN_DIFFS.listSessions; + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + expect(v1List).toHaveLength(1); + // Closing an already-closed session is a no-op on both engines. + await Promise.all([ + pair.v1.closeSession({ sessionId: 'session_parity_close' }), + pair.v2.closeSession({ sessionId: 'session_parity_close' }), + ]); + const [v1Again, v2Again] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2Again, pair.v2Home), 'id')).toEqual( + normalize(project(v1Again, pair.v1Home), 'id'), + ); + } finally { + await closeSessionPair(pair); + } + }); + + it('forkSession copies the session with merged metadata on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { + id: 'session_parity_source', + metadata: { origin: 'source', shared: 'source' }, + }); + // See materializeMainAgentOnBoth: v2's main agent is lazy, so + // materialize it on the source first. + await materializeMainAgentOnBoth(pair, 'session_parity_source'); + const input = { + id: 'session_parity_source', + forkId: 'session_parity_fork', + title: 'Parity Fork', + metadata: { fork: 'yes', shared: 'fork' }, + } as const; + const [v1Fork, v2Fork] = await Promise.all([ + pair.v1.forkSession(input), + pair.v2.forkSession(input), + ]); + const project = KNOWN_DIFFS.forkSession; + expect(project(v2Fork as ResumedSessionSummary, pair.v2Home)).toEqual( + project(v1Fork as ResumedSessionSummary, pair.v1Home), + ); + // The fork merges source and caller custom metadata (goal excluded). + expect(v1Fork.metadata).toEqual({ origin: 'source', shared: 'fork', fork: 'yes' }); + expect(v2Fork.metadata).toEqual({ origin: 'source', shared: 'fork', fork: 'yes' }); + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + const projectList = KNOWN_DIFFS.listSessions; + expect(normalize(projectList(v2List, pair.v2Home), 'id')).toEqual( + normalize(projectList(v1List, pair.v1Home), 'id'), + ); + expect(v1List.map((summary) => summary.id).toSorted()).toEqual([ + 'session_parity_fork', + 'session_parity_source', + ]); + } finally { + await closeSessionPair(pair); + } + }); + + it('resumeSession returns the same session state modulo the pinned agents gap', async () => { + const pair = await makeSessionParityPair(); + const extraDir = await makeTempDir('kimi-sdk-parity-extra-'); + try { + await createOnBoth(pair, { + id: 'session_parity_resume', + metadata: { origin: 'resume' }, + }); + await Promise.all([ + pair.v1.closeSession({ sessionId: 'session_parity_resume' }), + pair.v2.closeSession({ sessionId: 'session_parity_resume' }), + ]); + // Caller-provided additional dirs re-resolve on resume on both engines. + const [v1Resumed, v2Resumed] = await Promise.all([ + pair.v1.resumeSession({ id: 'session_parity_resume', additionalDirs: [extraDir] }), + pair.v2.resumeSession({ id: 'session_parity_resume', additionalDirs: [extraDir] }), + ]); + const project = KNOWN_DIFFS.resumeSession; + expect(project(v2Resumed, pair.v2Home)).toEqual(project(v1Resumed, pair.v1Home)); + // Both engines return the per-agent snapshot of the restored main agent. + expect(Object.keys(v1Resumed.agents)).toEqual(['main']); + expect(Object.keys(v2Resumed.agents)).toEqual(['main']); + expect(v1Resumed.additionalDirs).toEqual([extraDir]); + expect(v2Resumed.additionalDirs).toEqual([extraDir]); + await expect(pair.v1.resumeSession({ id: 'session_missing' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + await expect(pair.v2.resumeSession({ id: 'session_missing' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + } finally { + await closeSessionPair(pair); + } + }); + + it('reloadSession re-materializes a live session identically', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { + id: 'session_parity_reload', + metadata: { origin: 'reload' }, + }); + const [v1Resumed, v2Resumed] = await Promise.all([ + pair.v1.reloadSession({ sessionId: 'session_parity_reload' }), + pair.v2.reloadSession({ sessionId: 'session_parity_reload' }), + ]); + const project = KNOWN_DIFFS.reloadSession; + expect(project(v2Resumed, pair.v2Home)).toEqual(project(v1Resumed, pair.v1Home)); + expect(Object.keys(v1Resumed.agents)).toEqual(['main']); + expect(Object.keys(v2Resumed.agents)).toEqual(['main']); + } finally { + await closeSessionPair(pair); + } + }); + + it('updateSessionMetadata merges into the custom map on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { + id: 'session_parity_metadata', + metadata: { a: '1', shared: 'old' }, + }); + await Promise.all([ + pair.v1.updateSessionMetadata({ + sessionId: 'session_parity_metadata', + metadata: { b: '2', shared: 'new' }, + }), + pair.v2.updateSessionMetadata({ + sessionId: 'session_parity_metadata', + metadata: { b: '2', shared: 'new' }, + }), + ]); + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + const project = KNOWN_DIFFS.listSessions; + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + expect(v1List[0]?.metadata).toEqual({ a: '1', b: '2', shared: 'new' }); + expect(v2List[0]?.metadata).toEqual({ a: '1', b: '2', shared: 'new' }); + } finally { + await closeSessionPair(pair); + } + }); + + it('addAdditionalDir returns the same result for persist and non-persist', async () => { + const pair = await makeSessionParityPair(); + const persistedDir = await makeTempDir('kimi-sdk-parity-extra-persisted-'); + const sessionOnlyDir = await makeTempDir('kimi-sdk-parity-extra-session-'); + try { + await createOnBoth(pair, { id: 'session_parity_adddir' }); + // Both engines read and write the SAME workspace local config (shared + // workDir), so same-file mutations stay sequential — v1's write is not + // serialized against v2's read (same lesson as the plugin manager). + const v1Persisted = await pair.v1.addAdditionalDir({ + id: 'session_parity_adddir', + path: persistedDir, + persist: true, + }); + const v2Persisted = await pair.v2.addAdditionalDir({ + id: 'session_parity_adddir', + path: persistedDir, + persist: true, + }); + expect(v2Persisted).toEqual(v1Persisted); + expect(v1Persisted).toMatchObject({ + additionalDirs: [persistedDir], + projectRoot: pair.workDir, + configPath: join(pair.workDir, '.kimi-code', 'local.toml'), + persisted: true, + }); + const v1SessionOnly = await pair.v1.addAdditionalDir({ + id: 'session_parity_adddir', + path: sessionOnlyDir, + persist: false, + }); + const v2SessionOnly = await pair.v2.addAdditionalDir({ + id: 'session_parity_adddir', + path: sessionOnlyDir, + persist: false, + }); + expect(v2SessionOnly).toEqual(v1SessionOnly); + expect(v1SessionOnly).toMatchObject({ + additionalDirs: [persistedDir, sessionOnlyDir], + persisted: false, + }); + // v1 requires the active session on both engines. + await expect( + pair.v1.addAdditionalDir({ id: 'session_missing', path: persistedDir, persist: true }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.addAdditionalDir({ id: 'session_missing', path: persistedDir, persist: true }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + } + }); +}); + +// --------------------------------------------------------------------------- +// Agent interaction parity +// +// Same driving rule as the session/plugin batches: `SDKRpcClient` / +// `SDKRpcClientV2` directly, isolated per-engine homes, one SHARED workDir, +// explicit session ids. Every case here needs a configured default model +// (v1 applies it to the main agent eagerly at createSession; the v2 SDK +// binds the default profile on first agent touch), so both homes get +// AGENT_CONFIG_TOML before the clients are constructed. No provider calls: +// the fixture providers are never asked for a completion — compaction's +// success path is the one method that would fire a real summarizer round, +// so its parity stops at the pre-provider rejection (pinned below and in +// the migration tracker). +// --------------------------------------------------------------------------- + +/** + * Agent fixture: a kimi-typed provider (strict thinking validation on both + * engines) whose default model declares a thinking effort list, plus a + * second provider/model for the setModel switch case. + */ +const AGENT_CONFIG_TOML = ` +default_provider = "fixture-provider" +default_model = "fixture-model" +default_permission_mode = "auto" + +[providers.fixture-provider] +type = "kimi" +api_key = "fixture-api-key" +base_url = "https://example.com/v1" + +[providers.second-provider] +type = "openai" +api_key = "second-api-key" + +[models.fixture-model] +provider = "fixture-provider" +model = "kimi-for-coding" +max_context_size = 262144 +capabilities = ["thinking"] +support_efforts = ["low", "high"] +default_effort = "high" + +[models.second-model] +provider = "second-provider" +model = "second-brain" +max_context_size = 128000 +`; + +async function makeAgentParityPair(configToml: string = AGENT_CONFIG_TOML): Promise { + const v1HomeDir = await makeTempDir('kimi-sdk-parity-v1-home-'); + const v2HomeDir = await makeTempDir('kimi-sdk-parity-v2-home-'); + const workDir = await makeTempDir('kimi-sdk-parity-work-'); + await writeFile(join(v1HomeDir, 'config.toml'), configToml, 'utf-8'); + await writeFile(join(v2HomeDir, 'config.toml'), configToml, 'utf-8'); + return { + v1: new SDKRpcClient({ homeDir: v1HomeDir, identity: TEST_IDENTITY }), + v2: new SDKRpcClientV2({ homeDir: v2HomeDir, identity: TEST_IDENTITY }), + v1Home: { raw: v1HomeDir, real: await realpath(v1HomeDir) }, + v2Home: { raw: v2HomeDir, real: await realpath(v2HomeDir) }, + workDir, + }; +} + +describe('v1↔v2 agent interaction parity', () => { + it('getStatus / getContext / getUsage / getPlan match on a fresh session', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_fresh' }); + const input = { sessionId: 'session_parity_agent_fresh' } as const; + const [v1Status, v2Status] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Status, '')).toEqual(normalize(v1Status, '')); + // The eager-default state both engines arrive at from the fixture: + // default model + its default effort + the configured permission mode. + expect(v1Status).toEqual({ + model: 'fixture-model', + thinkingEffort: 'high', + permission: 'auto', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 262144, + contextUsage: 0, + usage: undefined, + }); + const [v1Context, v2Context] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + const projectContext = KNOWN_DIFFS.getContext; + expect(projectContext(v2Context)).toEqual(projectContext(v1Context)); + expect(v1Context).toEqual({ history: [], tokenCount: 0 }); + const [v1Usage, v2Usage] = await Promise.all([ + pair.v1.getUsage(input), + pair.v2.getUsage(input), + ]); + expect(normalize(v2Usage, '')).toEqual(normalize(v1Usage, '')); + const [v1Plan, v2Plan] = await Promise.all([ + pair.v1.getPlan(input), + pair.v2.getPlan(input), + ]); + expect(v2Plan).toEqual(v1Plan); + expect(v1Plan).toBeNull(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('getStatus matches on a home with no config at all (model-less session)', async () => { + const restoreEnv = scrubConfigEnv(); + // No config.toml on either side: v1's eager main agent has no model; the + // v2 SDK leaves the main agent unbound (its bind swallows + // model.not_configured) — the two read back identically. + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_noconfig' }); + const input = { sessionId: 'session_parity_agent_noconfig' } as const; + const [v1Status, v2Status] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Status, '')).toEqual(normalize(v1Status, '')); + expect(v1Status).toEqual({ + model: undefined, + thinkingEffort: 'off', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + usage: undefined, + }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('setModel returns the resolved provider and switches models identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_setmodel' }); + const input = { sessionId: 'session_parity_agent_setmodel' } as const; + const [v1Set, v2Set] = await Promise.all([ + pair.v1.setModel({ ...input, model: 'second-model' }), + pair.v2.setModel({ ...input, model: 'second-model' }), + ]); + expect(v2Set).toEqual(v1Set); + expect(v1Set).toEqual({ model: 'second-model', providerName: 'second-provider' }); + const [v1Status, v2Status] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Status, '')).toEqual(normalize(v1Status, '')); + expect(v1Status.model).toBe('second-model'); + expect(v1Status.maxContextTokens).toBe(128000); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('setModel rejects an unknown alias with the same code on both engines', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_setmodel_bad' }); + const input = { sessionId: 'session_parity_agent_setmodel_bad', model: 'missing-model' }; + // Both validate the alias up front and reject `config.invalid`; only + // the trailing guidance differs (v1 names the missing section). + const rejection = 'Model "missing-model" is not configured in config.toml.'; + await expect(pair.v1.setModel(input)).rejects.toMatchObject({ + code: ErrorCodes.CONFIG_INVALID, + }); + await expect(pair.v1.setModel(input)).rejects.toThrowError(rejection); + await expect(pair.v2.setModel(input)).rejects.toMatchObject({ + code: ErrorCodes.CONFIG_INVALID, + }); + await expect(pair.v2.setModel(input)).rejects.toThrowError(rejection); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('setThinking applies and rejects unlisted efforts identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_thinking' }); + const input = { sessionId: 'session_parity_agent_thinking' } as const; + await Promise.all([ + pair.v1.setThinking({ ...input, effort: 'low' }), + pair.v2.setThinking({ ...input, effort: 'low' }), + ]); + const [v1Low, v2Low] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Low, '')).toEqual(normalize(v1Low, '')); + expect(v1Low.thinkingEffort).toBe('low'); + // An unlisted effort on a strict-thinking (kimi-typed) model rejects + // with the same code AND the same message on both engines. + const rejection = + 'Thinking effort "bogus" is not supported by model "fixture-model". ' + + 'Supported efforts: off, low, high.'; + await expect(pair.v1.setThinking({ ...input, effort: 'bogus' })).rejects.toMatchObject({ + code: ErrorCodes.MODEL_CONFIG_INVALID, + }); + await expect(pair.v1.setThinking({ ...input, effort: 'bogus' })).rejects.toThrowError( + rejection, + ); + await expect(pair.v2.setThinking({ ...input, effort: 'bogus' })).rejects.toMatchObject({ + code: ErrorCodes.MODEL_CONFIG_INVALID, + }); + await expect(pair.v2.setThinking({ ...input, effort: 'bogus' })).rejects.toThrowError( + rejection, + ); + await Promise.all([ + pair.v1.setThinking({ ...input, effort: 'off' }), + pair.v2.setThinking({ ...input, effort: 'off' }), + ]); + const [v1Off, v2Off] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Off, '')).toEqual(normalize(v1Off, '')); + expect(v1Off.thinkingEffort).toBe('off'); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('setPermission flips the mode on both engines', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_permission' }); + const input = { sessionId: 'session_parity_agent_permission' } as const; + await Promise.all([ + pair.v1.setPermission({ ...input, mode: 'yolo' }), + pair.v2.setPermission({ ...input, mode: 'yolo' }), + ]); + const [v1Yolo, v2Yolo] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Yolo, '')).toEqual(normalize(v1Yolo, '')); + expect(v1Yolo.permission).toBe('yolo'); + await Promise.all([ + pair.v1.setPermission({ ...input, mode: 'manual' }), + pair.v2.setPermission({ ...input, mode: 'manual' }), + ]); + const [v1Manual, v2Manual] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(normalize(v2Manual, '')).toEqual(normalize(v1Manual, '')); + expect(v1Manual.permission).toBe('manual'); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('plan mode lifecycle matches: enter / getPlan / write / clear / cancel', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_plan' }); + const input = { sessionId: 'session_parity_agent_plan' } as const; + await Promise.all([ + pair.v1.setPlanMode({ ...input, enabled: true }), + pair.v2.setPlanMode({ ...input, enabled: true }), + ]); + const [v1Plan, v2Plan] = await Promise.all([ + pair.v1.getPlan(input), + pair.v2.getPlan(input), + ]); + const projectPlan = KNOWN_DIFFS.getPlan; + expect(projectPlan(v2Plan, pair.v2Home)).toEqual(projectPlan(v1Plan, pair.v1Home)); + expect(v1Plan?.id.length).toBeGreaterThan(0); + expect(v2Plan?.id.length).toBeGreaterThan(0); + expect(v1Plan?.content).toBe(''); + const [v1Status, v2Status] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + expect(v1Status.planMode).toBe(true); + expect(v2Status.planMode).toBe(true); + // Plan content round-trips through the plan file on both engines. + expect(v1Plan).not.toBeNull(); + expect(v2Plan).not.toBeNull(); + await writeFile(v1Plan!.path, '# Parity plan', 'utf-8'); + await writeFile(v2Plan!.path, '# Parity plan', 'utf-8'); + const [v1Filled, v2Filled] = await Promise.all([ + pair.v1.getPlan(input), + pair.v2.getPlan(input), + ]); + expect(projectPlan(v2Filled, pair.v2Home)).toEqual(projectPlan(v1Filled, pair.v1Home)); + expect(v1Filled?.content).toBe('# Parity plan'); + await Promise.all([pair.v1.clearPlan(input), pair.v2.clearPlan(input)]); + const [v1Cleared, v2Cleared] = await Promise.all([ + pair.v1.getPlan(input), + pair.v2.getPlan(input), + ]); + expect(projectPlan(v2Cleared, pair.v2Home)).toEqual(projectPlan(v1Cleared, pair.v1Home)); + expect(v1Cleared?.content).toBe(''); + // A second enter while active rejects with the same plain error. + await expect(pair.v1.setPlanMode({ ...input, enabled: true })).rejects.toThrowError( + 'Already in plan mode', + ); + await expect(pair.v2.setPlanMode({ ...input, enabled: true })).rejects.toThrowError( + 'Already in plan mode', + ); + await Promise.all([ + pair.v1.setPlanMode({ ...input, enabled: false }), + pair.v2.setPlanMode({ ...input, enabled: false }), + ]); + const [v1Off, v2Off] = await Promise.all([ + pair.v1.getPlan(input), + pair.v2.getPlan(input), + ]); + expect(v2Off).toEqual(v1Off); + expect(v1Off).toBeNull(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('importContext appends the byte-identical message and validates identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_import' }); + const input = { sessionId: 'session_parity_agent_import' } as const; + await Promise.all([ + pair.v1.importContext({ ...input, content: 'Earlier user: keep the API stable.', source: "file 'notes.md'" }), + pair.v2.importContext({ ...input, content: 'Earlier user: keep the API stable.', source: "file 'notes.md'" }), + ]); + const [v1Context, v2Context] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + // Histories are byte-identical (same wrapper, same wire record). + expect(normalize(v2Context.history, '')).toEqual(normalize(v1Context.history, '')); + expect(v1Context.history).toHaveLength(1); + expect(v1Context.history[0]).toMatchObject({ role: 'user', origin: { kind: 'user' } }); + // The pinned divergence (KNOWN_DIFFS.getContext): v1 adopts the import + // estimate as its reported count; v2's reported count is + // provider-measured and stays 0 until the first LLM round. + expect(v1Context.tokenCount).toBeGreaterThan(0); + expect(v2Context.tokenCount).toBe(0); + // v1's validations, replicated on the v2 side. + await expect( + pair.v1.importContext({ ...input, content: ' \n\t ', source: "file 'empty.md'" }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + await expect( + pair.v2.importContext({ ...input, content: ' \n\t ', source: "file 'empty.md'" }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + await expect( + pair.v1.importContext({ ...input, content: 'x', source: ' ' }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + await expect( + pair.v2.importContext({ ...input, content: 'x', source: ' ' }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + // A second import appends. + await Promise.all([ + pair.v1.importContext({ ...input, content: 'Second import.', source: "session 'old'" }), + pair.v2.importContext({ ...input, content: 'Second import.', source: "session 'old'" }), + ]); + const [v1After, v2After] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + expect(normalize(v2After.history, '')).toEqual(normalize(v1After.history, '')); + expect(v1After.history).toHaveLength(2); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('importContext rejects an overflowing import with context.overflow on both engines', async () => { + const restoreEnv = scrubConfigEnv(); + const tinyConfig = AGENT_CONFIG_TOML.replace('max_context_size = 262144', 'max_context_size = 16'); + const pair = await makeAgentParityPair(tinyConfig); + try { + await createOnBoth(pair, { id: 'session_parity_agent_import_overflow' }); + const input = { + sessionId: 'session_parity_agent_import_overflow', + content: 'This import is far too large for the tiny fixture context window.', + source: "file 'big.md'", + } as const; + await expect(pair.v1.importContext(input)).rejects.toMatchObject({ + code: ErrorCodes.CONTEXT_OVERFLOW, + }); + await expect(pair.v2.importContext(input)).rejects.toMatchObject({ + code: ErrorCodes.CONTEXT_OVERFLOW, + }); + // Neither engine appended anything. + const [v1Context, v2Context] = await Promise.all([ + pair.v1.getContext({ sessionId: input.sessionId }), + pair.v2.getContext({ sessionId: input.sessionId }), + ]); + expect(normalize(v2Context, '')).toEqual(normalize(v1Context, '')); + expect(v1Context.history).toHaveLength(0); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('resumeSession replays the same per-agent history and state snapshot', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_resume_replay' }); + const input = { sessionId: 'session_parity_resume_replay' } as const; + // History without a single provider call: an import, a mode switch to a + // NON-default mode (v1 journals even an unchanged setMode where v2 + // dedupes — a distinct mode records on both), plan mode, a goal + // lifecycle, and a shell command. + await Promise.all([ + pair.v1.importContext({ ...input, content: 'Resume replay import.', source: "file 'r.md'" }), + pair.v2.importContext({ ...input, content: 'Resume replay import.', source: "file 'r.md'" }), + ]); + // v2's context append journals through the agent's activity lane and + // can land after a subsequent immediate-write record; let it settle so + // both wires record the same order. + await settleTurns(); + await Promise.all([ + pair.v1.setPermission({ ...input, mode: 'manual' }), + pair.v2.setPermission({ ...input, mode: 'manual' }), + ]); + await Promise.all([ + pair.v1.setPlanMode({ ...input, enabled: true }), + pair.v2.setPlanMode({ ...input, enabled: true }), + ]); + await Promise.all([ + pair.v1.createGoal({ ...input, objective: 'resume replay goal' }), + pair.v2.createGoal({ ...input, objective: 'resume replay goal' }), + ]); + // Pause before close: an ACTIVE goal is demoted by the resume itself + // (v2 journals the demote op, v1 logs it live post-replay), which is a + // legitimate resume-time divergence this fixture does not exercise. + await Promise.all([pair.v1.pauseGoal(input), pair.v2.pauseGoal(input)]); + const [v1Shell, v2Shell] = await Promise.all([ + pair.v1.runShellCommand({ ...input, command: 'echo parity-resume' }), + pair.v2.runShellCommand({ ...input, command: 'echo parity-resume' }), + ]); + expect(v2Shell).toEqual(v1Shell); + await Promise.all([ + pair.v1.closeSession(input), + pair.v2.closeSession(input), + ]); + // The same tool-store record through both restore paths (the TUI's + // todo panel reads `toolStore['todo']`). + const todoRecord: JsonObject = { + type: 'tools.update_store', + key: 'todo', + value: [{ title: 'parity todo', status: 'pending' }], + time: Date.now(), + }; + await appendMainWireRecord(pair.v1Home, input.sessionId, todoRecord); + await appendMainWireRecord(pair.v2Home, input.sessionId, todoRecord); + + const [v1Resumed, v2Resumed] = await Promise.all([ + pair.v1.resumeSession({ id: input.sessionId }), + pair.v2.resumeSession({ id: input.sessionId }), + ]); + const project = KNOWN_DIFFS.resumeSession; + expect(project(v2Resumed, pair.v2Home)).toEqual(project(v1Resumed, pair.v1Home)); + // The projections, made explicit: identical replay type sequences once + // the bind-time `config_updated` entries drop out of v1's, identical + // message contents, and the injected todo store on both sides. + const v1Agent = v1Resumed.agents['main']!; + const v2Agent = v2Resumed.agents['main']!; + expect(v2Agent.replay.map((record) => record.type)).toEqual( + v1Agent.replay.filter((record) => record.type !== 'config_updated').map((record) => record.type), + ); + expect(v2Agent.replay.map((record) => record.type)).toEqual([ + 'permission_updated', + 'message', + 'permission_updated', + 'plan_updated', + 'goal_updated', + 'goal_updated', + 'message', + 'message', + ]); + const v1Messages = v1Agent.replay.flatMap((record) => + record.type === 'message' ? [record.message] : [], + ); + const v2Messages = v2Agent.replay.flatMap((record) => + record.type === 'message' ? [record.message] : [], + ); + expect(normalize(v2Messages, '')).toEqual(normalize(v1Messages, '')); + expect(v1Messages).toHaveLength(3); + expect(v2Agent.toolStore).toEqual(v1Agent.toolStore); + expect(v1Agent.toolStore).toEqual({ + todo: [{ title: 'parity todo', status: 'pending' }], + }); + expect(v2Agent.background).toEqual([]); + expect(v1Agent.background).toEqual([]); + + // replayTurnLimit trims both replays to the most recent N user turns + // (the shell command's input line is the last turn boundary here). + const [v1Limited, v2Limited] = await Promise.all([ + pair.v1.resumeSession({ id: input.sessionId, replayTurnLimit: 1 }), + pair.v2.resumeSession({ id: input.sessionId, replayTurnLimit: 1 }), + ]); + expect(project(v2Limited, pair.v2Home)).toEqual(project(v1Limited, pair.v1Home)); + const v1LimitedTypes = v1Limited.agents['main']!.replay.map((record) => record.type); + expect(v1LimitedTypes).toEqual(['message', 'message']); + expect(v2Limited.agents['main']!.replay.map((record) => record.type)).toEqual( + v1LimitedTypes, + ); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('clearContext empties both histories', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_clear' }); + const input = { sessionId: 'session_parity_agent_clear' } as const; + await Promise.all([ + pair.v1.importContext({ ...input, content: 'To be cleared.', source: "file 'x.md'" }), + pair.v2.importContext({ ...input, content: 'To be cleared.', source: "file 'x.md'" }), + ]); + await Promise.all([pair.v1.clearContext(input), pair.v2.clearContext(input)]); + const [v1Context, v2Context] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + expect(normalize(v2Context, '')).toEqual(normalize(v1Context, '')); + expect(v1Context).toEqual({ history: [], tokenCount: 0 }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('undoHistory removes the same turn suffix', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_undo' }); + const input = { sessionId: 'session_parity_agent_undo' } as const; + await Promise.all([ + pair.v1.importContext({ ...input, content: 'First import.', source: "file 'one.md'" }), + pair.v2.importContext({ ...input, content: 'First import.', source: "file 'one.md'" }), + ]); + await Promise.all([ + pair.v1.importContext({ ...input, content: 'Second import.', source: "file 'two.md'" }), + pair.v2.importContext({ ...input, content: 'Second import.', source: "file 'two.md'" }), + ]); + await Promise.all([ + pair.v1.undoHistory({ ...input, count: 1 }), + pair.v2.undoHistory({ ...input, count: 1 }), + ]); + const projectContext = KNOWN_DIFFS.getContext; + const [v1OneLeft, v2OneLeft] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + expect(projectContext(v2OneLeft)).toEqual(projectContext(v1OneLeft)); + expect(v1OneLeft.history).toHaveLength(1); + await Promise.all([ + pair.v1.undoHistory({ ...input, count: 1 }), + pair.v2.undoHistory({ ...input, count: 1 }), + ]); + const [v1Empty, v2Empty] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + expect(normalize(v2Empty, '')).toEqual(normalize(v1Empty, '')); + expect(v1Empty.history).toHaveLength(0); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('undoHistory failure modes differ as pinned (v1 partial + request.invalid, v2 atomic session.undo_unavailable)', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_undo_fail' }); + const input = { sessionId: 'session_parity_agent_undo_fail' } as const; + // Empty history: v1 is a silent no-op, v2 rejects atomically. + await pair.v1.undoHistory({ ...input, count: 1 }); + await expect(pair.v2.undoHistory({ ...input, count: 1 })).rejects.toMatchObject({ + code: 'session.undo_unavailable', + }); + await Promise.all([ + pair.v1.importContext({ ...input, content: 'Only turn.', source: "file 'one.md'" }), + pair.v2.importContext({ ...input, content: 'Only turn.', source: "file 'one.md'" }), + ]); + // More than available: v1 splices the partial suffix out of the live + // history and THEN throws request.invalid; v2 prechecks and rejects + // with session.undo_unavailable without touching the history. + await expect(pair.v1.undoHistory({ ...input, count: 2 })).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + }); + await expect(pair.v2.undoHistory({ ...input, count: 2 })).rejects.toMatchObject({ + code: 'session.undo_unavailable', + }); + const [v1Context, v2Context] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + expect(v1Context.history).toHaveLength(0); + expect(v2Context.history).toHaveLength(1); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('compact rejects on an empty history; cancelCompaction is an idle no-op', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_compact' }); + const input = { sessionId: 'session_parity_agent_compact' } as const; + // The manual-compaction success path fires a real summarizer round on + // both engines, so parity stops at the pre-provider rejection (no + // history → compaction.unable); the success path is registered as + // provider-boundary-only in the migration tracker. + await expect(pair.v1.compact(input)).rejects.toMatchObject({ + code: ErrorCodes.COMPACTION_UNABLE, + }); + await expect(pair.v2.compact(input)).rejects.toMatchObject({ + code: ErrorCodes.COMPACTION_UNABLE, + }); + await pair.v1.cancelCompaction(input); + await pair.v2.cancelCompaction(input); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('createSession applies model / thinking / permission to the main agent', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + const input = { + id: 'session_parity_agent_create_opts', + workDir: pair.workDir, + model: 'fixture-model', + thinking: 'low', + permission: 'yolo', + } as const; + await Promise.all([pair.v1.createSession(input), pair.v2.createSession(input)]); + const statusInput = { sessionId: input.id } as const; + const [v1Status, v2Status] = await Promise.all([ + pair.v1.getStatus(statusInput), + pair.v2.getStatus(statusInput), + ]); + expect(normalize(v2Status, '')).toEqual(normalize(v1Status, '')); + expect(v1Status).toMatchObject({ + model: 'fixture-model', + thinkingEffort: 'low', + permission: 'yolo', + }); + // v1 never validates the create-time effort: an unlisted value + // normalizes to the model default. The v2 bind (deliberately + // non-strict) resolves it the same way. + const drifted = { + id: 'session_parity_agent_create_drift', + workDir: pair.workDir, + thinking: 'bogus', + } as const; + await Promise.all([pair.v1.createSession(drifted), pair.v2.createSession(drifted)]); + const [v1Drift, v2Drift] = await Promise.all([ + pair.v1.getStatus({ sessionId: drifted.id }), + pair.v2.getStatus({ sessionId: drifted.id }), + ]); + expect(normalize(v2Drift, '')).toEqual(normalize(v1Drift, '')); + expect(v1Drift.thinkingEffort).toBe('high'); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('createSession with an unknown model: v1 records it, v2 rejects up front (pinned)', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + // Pinned difference: v1 records an unknown alias at create without + // resolving it (the failure defers to the first prompt); v2's bind + // resolves through the model catalog up front and rejects — with the + // same `config.invalid` code setModel uses. + await expect( + pair.v1.createSession({ + id: 'session_parity_agent_create_bad', + workDir: pair.workDir, + model: 'missing-model', + }), + ).resolves.toMatchObject({ id: 'session_parity_agent_create_bad' }); + await expect( + pair.v2.createSession({ + id: 'session_parity_agent_create_bad', + workDir: pair.workDir, + model: 'missing-model', + }), + ).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('agent-scoped calls reject an unknown agent id identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_ghost' }); + const input = { sessionId: 'session_parity_agent_ghost' } as const; + await expect( + pair.v1.withInteractiveAgent('ghost', () => pair.v1.getStatus(input)), + ).rejects.toMatchObject({ code: ErrorCodes.AGENT_NOT_FOUND }); + await expect( + pair.v2.withInteractiveAgent('ghost', () => pair.v2.getStatus(input)), + ).rejects.toMatchObject({ code: ErrorCodes.AGENT_NOT_FOUND }); + await expect( + pair.v1.withInteractiveAgent('ghost', () => + pair.v1.setModel({ ...input, model: 'fixture-model' }), + ), + ).rejects.toMatchObject({ code: ErrorCodes.AGENT_NOT_FOUND }); + await expect( + pair.v2.withInteractiveAgent('ghost', () => + pair.v2.setModel({ ...input, model: 'fixture-model' }), + ), + ).rejects.toMatchObject({ code: ErrorCodes.AGENT_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('runShellCommand executes and records identically; cancelShellCommand is a silent no-op', async () => { + const restoreEnv = scrubConfigEnv(); + // Configured home: v1 only assembles its builtin tools on a profiled + // agent, so a model-less v1 session answers "Bash tool is not + // available." where v2 runs the command (not pinned — the fixture simply + // needs a bound model on both sides). Shell commands never start a turn, + // so this stays pre-provider. + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_shell' }); + const input = { sessionId: 'session_parity_agent_shell' } as const; + const [v1Shell, v2Shell] = await Promise.all([ + pair.v1.runShellCommand({ ...input, command: 'echo out; echo err >&2', commandId: 'cmd-mixed' }), + pair.v2.runShellCommand({ ...input, command: 'echo out; echo err >&2', commandId: 'cmd-mixed' }), + ]); + expect(v2Shell).toEqual(v1Shell); + expect(v1Shell).toEqual({ stdout: 'out\n', stderr: 'err\n', isError: false }); + const [v1Exit, v2Exit] = await Promise.all([ + pair.v1.runShellCommand({ ...input, command: 'exit 3' }), + pair.v2.runShellCommand({ ...input, command: 'exit 3' }), + ]); + expect(v2Exit).toEqual(v1Exit); + expect(v1Exit).toEqual({ + stdout: '', + stderr: 'Process exited with code 3\nCommand failed with exit code: 3.', + isError: true, + }); + // Cancelling an unknown command id is a silent no-op on both engines. + await Promise.all([ + pair.v1.cancelShellCommand({ ...input, commandId: 'never-started' }), + pair.v2.cancelShellCommand({ ...input, commandId: 'never-started' }), + ]); + // The recorded shell_command history is byte-identical. + const [v1Context, v2Context] = await Promise.all([ + pair.v1.getContext(input), + pair.v2.getContext(input), + ]); + const projectContext = KNOWN_DIFFS.getContext; + expect(projectContext(v2Context)).toEqual(projectContext(v1Context)); + expect(v1Context.history).toHaveLength(4); + await expect( + pair.v1.runShellCommand({ sessionId: 'session_missing', command: 'true' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.runShellCommand({ sessionId: 'session_missing', command: 'true' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('prompt launches a turn and updates title/lastPrompt identically', async () => { + const restoreEnv = scrubConfigEnv(); + // Model-less on purpose: parity covers the pre-provider surface — the + // call returns without throwing and the metadata update (same shared + // helpers on both engines) lands before the turn fails asynchronously. + // The enqueue-semantics gaps (v1 drops a mid-turn prompt, v2 queues it; + // v1 ignores disabledTools, v2 applies it) are pinned in the tracker. + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_prompt' }); + const input = { sessionId: 'session_parity_agent_prompt' } as const; + await Promise.all([ + pair.v1.prompt({ ...input, input: [{ type: 'text', text: 'hello parity' }] }), + pair.v2.prompt({ ...input, input: [{ type: 'text', text: 'hello parity' }] }), + ]); + const project = KNOWN_DIFFS.listSessions; + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + expect(v1List[0]?.title).toBe('hello parity'); + expect(v1List[0]?.lastPrompt).toBe('hello parity'); + await expect( + pair.v1.prompt({ sessionId: 'session_missing', input: [{ type: 'text', text: 'x' }] }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.prompt({ sessionId: 'session_missing', input: [{ type: 'text', text: 'x' }] }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + // Let the model-less turns finish failing before close. + await settleTurns(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('steer on an idle session: v1 launches a turn, v2 rejects prompt.not_found (pinned)', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_steer' }); + const input = { sessionId: 'session_parity_agent_steer' } as const; + // Pinned divergence: v1 treats an idle steer like a prompt — it + // launches a fresh turn and updates title/lastPrompt. v2's steer RPC + // enqueues first (which itself launches the turn), so the follow-up + // steer step finds no pending prompt and rejects with prompt.not_found; + // the v2 path never touches the metadata. + await pair.v1.steer({ ...input, input: [{ type: 'text', text: 'steer text' }] }); + await expect( + pair.v2.steer({ ...input, input: [{ type: 'text', text: 'steer text' }] }), + ).rejects.toMatchObject({ code: 'prompt.not_found' }); + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(v1List[0]?.title).toBe('steer text'); + expect(v1List[0]?.lastPrompt).toBe('steer text'); + expect(v2List[0]?.lastPrompt).not.toBe('steer text'); + await settleTurns(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('activateSkill renders, launches, and rejects identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + // The skill must exist before either session is created (both engines + // load their skill catalog at session start). + await writeSkill(join(pair.workDir, '.kimi-code', 'skills', 'parity-skill'), 'parity-skill'); + try { + await createOnBoth(pair, { id: 'session_parity_agent_skill' }); + const input = { sessionId: 'session_parity_agent_skill' } as const; + await Promise.all([ + pair.v1.activateSkill({ ...input, name: 'parity-skill', args: 'some args' }), + pair.v2.activateSkill({ ...input, name: 'parity-skill', args: 'some args' }), + ]); + const project = KNOWN_DIFFS.listSessions; + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + expect(v1List[0]?.lastPrompt).toBe('/parity-skill some args'); + // An unknown skill rejects synchronously with the same code and text. + const rejection = 'Skill "missing-skill" was not found'; + await expect( + pair.v1.activateSkill({ ...input, name: 'missing-skill' }), + ).rejects.toMatchObject({ code: ErrorCodes.SKILL_NOT_FOUND }); + await expect(pair.v1.activateSkill({ ...input, name: 'missing-skill' })).rejects.toThrowError( + rejection, + ); + await expect( + pair.v2.activateSkill({ ...input, name: 'missing-skill' }), + ).rejects.toMatchObject({ code: ErrorCodes.SKILL_NOT_FOUND }); + await expect(pair.v2.activateSkill({ ...input, name: 'missing-skill' })).rejects.toThrowError( + rejection, + ); + await settleTurns(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('activatePluginCommand activates and rejects identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + const sourceDir = await makeTempDir('kimi-sdk-parity-plugin-src-'); + await writeFixturePlugin(sourceDir); + try { + await pair.v1.installPlugin(sourceDir); + await pair.v2.installPlugin(sourceDir); + // The v1 session connects the plugin's enabled MCP servers at + // creation; disable them first so the test stays offline (same rule + // as the plugin batch; same-engine mutations stay sequential). + await pair.v1.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false); + await pair.v1.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-http', false); + await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false); + await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-http', false); + // v1 resolves commands against the session's creation-time snapshot, + // so the session must be created after the install; v2 uses the + // app-global live view. + await createOnBoth(pair, { id: 'session_parity_agent_plugincmd' }); + const input = { sessionId: 'session_parity_agent_plugincmd' } as const; + // An unknown command rejects with the same code and text. + const rejection = 'Plugin command "p:c" was not found'; + await expect( + pair.v1.activatePluginCommand({ ...input, pluginId: 'p', commandName: 'c' }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + await expect( + pair.v1.activatePluginCommand({ ...input, pluginId: 'p', commandName: 'c' }), + ).rejects.toThrowError(rejection); + await expect( + pair.v2.activatePluginCommand({ ...input, pluginId: 'p', commandName: 'c' }), + ).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID }); + await expect( + pair.v2.activatePluginCommand({ ...input, pluginId: 'p', commandName: 'c' }), + ).rejects.toThrowError(rejection); + // The known command activates and updates title/lastPrompt identically. + await Promise.all([ + pair.v1.activatePluginCommand({ + ...input, + pluginId: FIXTURE_PLUGIN_ID, + commandName: 'parity-command', + }), + pair.v2.activatePluginCommand({ + ...input, + pluginId: FIXTURE_PLUGIN_ID, + commandName: 'parity-command', + }), + ]); + const project = KNOWN_DIFFS.listSessions; + const [v1List, v2List] = await Promise.all([ + pair.v1.listSessions(), + pair.v2.listSessions(), + ]); + expect(normalize(project(v2List, pair.v2Home), 'id')).toEqual( + normalize(project(v1List, pair.v1Home), 'id'), + ); + expect(v1List[0]?.lastPrompt).toBe('/parity-plugin:parity-command'); + await settleTurns(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('generateAgentsMd rejects model-less with session.init_failed on both engines (pinned message gap)', async () => { + const restoreEnv = scrubConfigEnv(); + // The success path spawns a real subagent LLM round (the /init brief), + // so parity stops at the model-less rejection — registered as + // provider-boundary-only in the migration tracker. + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_init' }); + const input = { sessionId: 'session_parity_agent_init' } as const; + // Same code; the message differs by design (pinned): v1 wraps the + // provider-resolution failure from the spawned init turn, v2 preflights + // the missing model binding. + await expect(pair.v1.generateAgentsMd(input)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_INIT_FAILED, + message: expect.stringContaining('LLM not set'), + }); + await expect(pair.v2.generateAgentsMd(input)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_INIT_FAILED, + message: 'Main agent has no model bound', + }); + await expect(pair.v1.generateAgentsMd({ sessionId: 'session_missing' })).rejects.toMatchObject( + { code: ErrorCodes.SESSION_NOT_FOUND }, + ); + await expect(pair.v2.generateAgentsMd({ sessionId: 'session_missing' })).rejects.toMatchObject( + { code: ErrorCodes.SESSION_NOT_FOUND }, + ); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('getSessionWarnings reports an oversized AGENTS.md identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_agent_warnings' }); + const input = { sessionId: 'session_parity_agent_warnings' } as const; + const [v1Empty, v2Empty] = await Promise.all([ + pair.v1.getSessionWarnings(input), + pair.v2.getSessionWarnings(input), + ]); + expect(v2Empty).toEqual(v1Empty); + expect(v1Empty).toEqual([]); + // Written after create on purpose: v1's cache holds no warning and + // recomputes on demand; the v2 SDK recomputes through the engine's own + // prepareSystemPromptContext — the mid-session growth surfaces on both. + await writeFile(join(pair.workDir, 'AGENTS.md'), 'a'.repeat(40 * 1024), 'utf-8'); + const [v1Warnings, v2Warnings] = await Promise.all([ + pair.v1.getSessionWarnings(input), + pair.v2.getSessionWarnings(input), + ]); + expect(v2Warnings).toEqual(v1Warnings); + expect(v1Warnings).toHaveLength(1); + expect(v1Warnings[0]).toMatchObject({ code: 'agents-md-oversized', severity: 'warning' }); + expect(v1Warnings[0]?.message).toContain('exceeds the recommended'); + await expect(pair.v1.getSessionWarnings({ sessionId: 'session_missing' })).rejects.toMatchObject( + { code: ErrorCodes.SESSION_NOT_FOUND }, + ); + await expect(pair.v2.getSessionWarnings({ sessionId: 'session_missing' })).rejects.toMatchObject( + { code: ErrorCodes.SESSION_NOT_FOUND }, + ); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Goal / cron / background tasks / print policy parity +// +// Same driving rule as the earlier batches: `SDKRpcClient` / `SDKRpcClientV2` +// directly, isolated per-engine homes, one SHARED workDir, explicit session +// ids. The goal state machine and the cron list need no model at all; the +// background-task cases use the configured home (v1 only assembles its +// builtin Bash tool on a profiled agent) and detach a FOREGROUND shell +// command into a real background task through the SDK surface — no provider +// calls anywhere. The print-policy cases pin the +// `background.print_background_mode` matrix (exit / drain / steer, the +// legacy keep_alive_on_exit fallback, and the steer caps). +// --------------------------------------------------------------------------- + +describe('v1↔v2 goal parity', () => { + it('goal lifecycle matches: create / get / pause / resume / replace / cancel', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_goal' }); + const input = { sessionId: 'session_parity_goal' } as const; + const [v1Created, v2Created] = await Promise.all([ + pair.v1.createGoal({ ...input, objective: 'Ship the parity goal.' }), + pair.v2.createGoal({ ...input, objective: 'Ship the parity goal.' }), + ]); + const projectCreate = KNOWN_DIFFS.createGoal; + expect(projectCreate(v2Created)).toEqual(projectCreate(v1Created)); + expect(v1Created).toMatchObject({ status: 'active', turnsUsed: 0, tokensUsed: 0 }); + expect(v1Created.goalId.length).toBeGreaterThan(0); + expect(v2Created.goalId.length).toBeGreaterThan(0); + // A second create without replace rejects identically. + await expect( + pair.v1.createGoal({ ...input, objective: 'Another goal.' }), + ).rejects.toMatchObject({ code: ErrorCodes.GOAL_ALREADY_EXISTS }); + await expect( + pair.v2.createGoal({ ...input, objective: 'Another goal.' }), + ).rejects.toMatchObject({ code: ErrorCodes.GOAL_ALREADY_EXISTS }); + // Objective validation matches (same codes, same message text). + await expect( + pair.v1.createGoal({ ...input, objective: ' ', replace: true }), + ).rejects.toMatchObject({ code: ErrorCodes.GOAL_OBJECTIVE_EMPTY }); + await expect( + pair.v2.createGoal({ ...input, objective: ' ', replace: true }), + ).rejects.toMatchObject({ code: ErrorCodes.GOAL_OBJECTIVE_EMPTY }); + await expect( + pair.v1.createGoal({ ...input, objective: 'x'.repeat(4001), replace: true }), + ).rejects.toMatchObject({ code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG }); + await expect( + pair.v2.createGoal({ ...input, objective: 'x'.repeat(4001), replace: true }), + ).rejects.toMatchObject({ code: ErrorCodes.GOAL_OBJECTIVE_TOO_LONG }); + const [v1Got, v2Got] = await Promise.all([pair.v1.getGoal(input), pair.v2.getGoal(input)]); + const projectGet = KNOWN_DIFFS.getGoal; + expect(projectGet(v2Got)).toEqual(projectGet(v1Got)); + expect(v1Got.goal).not.toBeNull(); + // Pause is a status transition and idempotent on both engines. + const [v1Paused, v2Paused] = await Promise.all([ + pair.v1.pauseGoal(input), + pair.v2.pauseGoal(input), + ]); + const projectPause = KNOWN_DIFFS.pauseGoal; + expect(projectPause(v2Paused)).toEqual(projectPause(v1Paused)); + expect(v1Paused.status).toBe('paused'); + const [v1PausedAgain, v2PausedAgain] = await Promise.all([ + pair.v1.pauseGoal(input), + pair.v2.pauseGoal(input), + ]); + expect(projectPause(v2PausedAgain)).toEqual(projectPause(v1PausedAgain)); + // Resume re-activates and clears the stop reason on both engines. + const [v1Resumed, v2Resumed] = await Promise.all([ + pair.v1.resumeGoal(input), + pair.v2.resumeGoal(input), + ]); + const projectResume = KNOWN_DIFFS.resumeGoal; + expect(projectResume(v2Resumed)).toEqual(projectResume(v1Resumed)); + expect(v1Resumed.status).toBe('active'); + expect(v1Resumed.terminalReason).toBeUndefined(); + expect(v2Resumed.terminalReason).toBeUndefined(); + // Replace swaps in a fresh goal on both engines. + const [v1Replaced, v2Replaced] = await Promise.all([ + pair.v1.createGoal({ ...input, objective: 'Replacement goal.', replace: true }), + pair.v2.createGoal({ ...input, objective: 'Replacement goal.', replace: true }), + ]); + expect(projectCreate(v2Replaced)).toEqual(projectCreate(v1Replaced)); + expect(v1Replaced).toMatchObject({ status: 'active', objective: 'Replacement goal.' }); + // Cancel returns the removed snapshot; afterwards the goal is gone. + const [v1Cancelled, v2Cancelled] = await Promise.all([ + pair.v1.cancelGoal(input), + pair.v2.cancelGoal(input), + ]); + const projectCancel = KNOWN_DIFFS.cancelGoal; + expect(projectCancel(v2Cancelled)).toEqual(projectCancel(v1Cancelled)); + expect(v1Cancelled.status).toBe('active'); + const [v1Empty, v2Empty] = await Promise.all([pair.v1.getGoal(input), pair.v2.getGoal(input)]); + expect(projectGet(v2Empty)).toEqual(projectGet(v1Empty)); + expect(v1Empty.goal).toBeNull(); + // Lifecycle calls without a goal reject identically. + await expect(pair.v1.cancelGoal(input)).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + await expect(pair.v2.cancelGoal(input)).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + await expect(pair.v1.pauseGoal(input)).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + await expect(pair.v2.pauseGoal(input)).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + await expect(pair.v1.resumeGoal(input)).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + await expect(pair.v2.resumeGoal(input)).rejects.toMatchObject({ + code: ErrorCodes.GOAL_NOT_FOUND, + }); + // Goal calls require a live session on both engines. + await expect( + pair.v1.createGoal({ sessionId: 'session_missing', objective: 'x' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.createGoal({ sessionId: 'session_missing', objective: 'x' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('an active goal survives close+resume with the same pause demotion on both engines', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_goal_resume' }); + const input = { sessionId: 'session_parity_goal_resume' } as const; + await Promise.all([ + pair.v1.createGoal({ ...input, objective: 'Persisted parity goal.' }), + pair.v2.createGoal({ ...input, objective: 'Persisted parity goal.' }), + ]); + await Promise.all([ + pair.v1.closeSession({ sessionId: input.sessionId }), + pair.v2.closeSession({ sessionId: input.sessionId }), + ]); + await Promise.all([ + pair.v1.resumeSession({ id: input.sessionId }), + pair.v2.resumeSession({ id: input.sessionId }), + ]); + // An active goal cannot still be running after a restart: both engines + // demote it to paused with the same reason on replay. + const [v1Got, v2Got] = await Promise.all([pair.v1.getGoal(input), pair.v2.getGoal(input)]); + const projectGet = KNOWN_DIFFS.getGoal; + expect(projectGet(v2Got)).toEqual(projectGet(v1Got)); + expect(v1Got.goal).toMatchObject({ + status: 'paused', + objective: 'Persisted parity goal.', + terminalReason: 'Paused after agent resume', + }); + // The demoted goal resumes identically. + const [v1Resumed, v2Resumed] = await Promise.all([ + pair.v1.resumeGoal(input), + pair.v2.resumeGoal(input), + ]); + const projectResume = KNOWN_DIFFS.resumeGoal; + expect(projectResume(v2Resumed)).toEqual(projectResume(v1Resumed)); + expect(v1Resumed.status).toBe('active'); + expect(v1Resumed.terminalReason).toBeUndefined(); + expect(v2Resumed.terminalReason).toBeUndefined(); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('getCronTasks returns the same empty list on a fresh session', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_cron' }); + const input = { sessionId: 'session_parity_cron' } as const; + const [v1Cron, v2Cron] = await Promise.all([ + pair.v1.getCronTasks(input), + pair.v2.getCronTasks(input), + ]); + expect(v2Cron).toEqual(v1Cron); + expect(v1Cron).toEqual({ tasks: [] }); + await expect(pair.v1.getCronTasks({ sessionId: 'session_missing' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + await expect(pair.v2.getCronTasks({ sessionId: 'session_missing' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); +}); + +// --- Background-task helpers ------------------------------------------------ + +interface ShellRunResult { + readonly stdout: string; + readonly stderr: string; + readonly isError?: boolean; + readonly backgrounded?: boolean; +} + +async function waitForBackgroundTask( + rpc: SDKRpcClientBase, + sessionId: string, + match: (task: BackgroundTaskInfo) => boolean, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const task = (await rpc.listBackgroundTasks({ sessionId })).find(match); + if (task !== undefined) return task; + if (Date.now() >= deadline) throw new Error('timed out waiting for a background task'); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +let shellCommandSeq = 0; + +/** + * Start a foreground shell command that stays running and detach it into a + * real background task — the only model-free way to create one through the + * SDK surface (the Bash tool registers foreground commands as tasks, and the + * detach releases the tool-call waiter, resolving the run as backgrounded). + */ +async function startDetachedBackgroundTask( + rpc: SDKRpcClientBase, + sessionId: string, + command: string, +): Promise<{ readonly taskId: string; readonly run: Promise }> { + shellCommandSeq += 1; + const run = rpc.runShellCommand({ + sessionId, + command, + commandId: `cmd-bg-${String(shellCommandSeq)}`, + }); + const task = await waitForBackgroundTask( + rpc, + sessionId, + (candidate) => candidate.status === 'running', + ); + await rpc.detachBackgroundTask({ sessionId, taskId: task.taskId }); + return { taskId: task.taskId, run }; +} + +async function stopAndSettle(rpc: SDKRpcClientBase, sessionId: string, taskId: string): Promise { + await rpc.stopBackgroundTask({ sessionId, taskId }); + // v1 fire-and-forgets the stop; poll both engines for the terminal state. + await waitForBackgroundTask( + rpc, + sessionId, + (candidate) => candidate.taskId === taskId && candidate.status !== 'running', + ); +} + +describe('v1↔v2 background task parity', () => { + it('task lifecycle matches: list / detach / output / stop', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_bgtask' }); + const input = { sessionId: 'session_parity_bgtask' } as const; + // Empty list and unknown-id behaviors match. + const [v1Empty, v2Empty] = await Promise.all([ + pair.v1.listBackgroundTasks(input), + pair.v2.listBackgroundTasks(input), + ]); + expect(v2Empty).toEqual(v1Empty); + expect(v1Empty).toEqual([]); + await expect( + pair.v1.getBackgroundTaskOutput({ ...input, taskId: 'bash-deadbeef' }), + ).resolves.toBe(''); + await expect( + pair.v2.getBackgroundTaskOutput({ ...input, taskId: 'bash-deadbeef' }), + ).resolves.toBe(''); + await expect( + pair.v1.detachBackgroundTask({ ...input, taskId: 'bash-deadbeef' }), + ).resolves.toBeUndefined(); + await expect( + pair.v2.detachBackgroundTask({ ...input, taskId: 'bash-deadbeef' }), + ).resolves.toBeUndefined(); + await expect( + pair.v1.stopBackgroundTask({ ...input, taskId: 'bash-deadbeef', reason: 'noop' }), + ).resolves.toBeUndefined(); + await expect( + pair.v2.stopBackgroundTask({ ...input, taskId: 'bash-deadbeef', reason: 'noop' }), + ).resolves.toBeUndefined(); + // Start a foreground command on both engines; it registers as a + // running (non-detached) task with the same info. + const v1Run = pair.v1.runShellCommand({ + ...input, + command: 'echo bg-out; sleep 30', + commandId: 'cmd-bg-detach', + }); + const v2Run = pair.v2.runShellCommand({ + ...input, + command: 'echo bg-out; sleep 30', + commandId: 'cmd-bg-detach', + }); + const [v1Running, v2Running] = await Promise.all([ + waitForBackgroundTask(pair.v1, input.sessionId, (task) => task.status === 'running'), + waitForBackgroundTask(pair.v2, input.sessionId, (task) => task.status === 'running'), + ]); + const projectList = KNOWN_DIFFS.listBackgroundTasks; + expect(projectList([v2Running])).toEqual(projectList([v1Running])); + expect(v1Running).toMatchObject({ + kind: 'process', + command: 'echo bg-out; sleep 30', + status: 'running', + detached: false, + }); + // The pinned timeoutMs gap only exists AFTER a detach; pre-detach both + // report the foreground deadline (the 120s shell foreground timeout). + expect(v1Running.timeoutMs).toBe(120_000); + expect(v2Running.timeoutMs).toBe(120_000); + expect(v1Running.taskId.startsWith('bash-')).toBe(true); + expect(v2Running.taskId.startsWith('bash-')).toBe(true); + // Detach releases the foreground waiter on both engines; the run + // resolves backgrounded with the task metadata payload. + const [v1Detached, v2Detached] = await Promise.all([ + pair.v1.detachBackgroundTask({ ...input, taskId: v1Running.taskId }), + pair.v2.detachBackgroundTask({ ...input, taskId: v2Running.taskId }), + ]); + const projectDetach = KNOWN_DIFFS.detachBackgroundTask; + expect(projectDetach(v2Detached)).toEqual(projectDetach(v1Detached)); + expect(v1Detached?.detached).toBe(true); + const [v1RunResult, v2RunResult] = await Promise.all([v1Run, v2Run]); + expect(v1RunResult.backgrounded).toBe(true); + expect(v2RunResult.backgrounded).toBe(true); + // Captured output (and the tail slice) reads back identically. + const [v1Output, v2Output] = await Promise.all([ + pair.v1.getBackgroundTaskOutput({ ...input, taskId: v1Running.taskId }), + pair.v2.getBackgroundTaskOutput({ ...input, taskId: v2Running.taskId }), + ]); + expect(v2Output).toEqual(v1Output); + expect(v1Output).toBe('bg-out\n'); + const [v1Tail, v2Tail] = await Promise.all([ + pair.v1.getBackgroundTaskOutput({ ...input, taskId: v1Running.taskId, tail: 4 }), + pair.v2.getBackgroundTaskOutput({ ...input, taskId: v2Running.taskId, tail: 4 }), + ]); + expect(v2Tail).toEqual(v1Tail); + expect(v1Tail).toBe('out\n'); + // Stop with an explicit reason settles to killed with the reason + // recorded (trimmed) on both engines. + await Promise.all([ + pair.v1.stopBackgroundTask({ ...input, taskId: v1Running.taskId, reason: 'parity stop' }), + pair.v2.stopBackgroundTask({ ...input, taskId: v2Running.taskId, reason: 'parity stop' }), + ]); + const [v1Killed, v2Killed] = await Promise.all([ + waitForBackgroundTask( + pair.v1, + input.sessionId, + (task) => task.taskId === v1Running.taskId && task.status !== 'running', + ), + waitForBackgroundTask( + pair.v2, + input.sessionId, + (task) => task.taskId === v2Running.taskId && task.status !== 'running', + ), + ]); + expect(projectList([v2Killed])).toEqual(projectList([v1Killed])); + expect(v1Killed).toMatchObject({ status: 'killed', stopReason: 'parity stop' }); + // Active-only filtering drops the terminal task on both engines. + const [v1Active, v2Active] = await Promise.all([ + pair.v1.listBackgroundTasks({ ...input, activeOnly: true }), + pair.v2.listBackgroundTasks({ ...input, activeOnly: true }), + ]); + expect(v2Active).toEqual(v1Active); + expect(v1Active).toEqual([]); + // Stopping an already-terminal task is a no-op on both engines. + await expect( + pair.v1.stopBackgroundTask({ ...input, taskId: v1Running.taskId }), + ).resolves.toBeUndefined(); + await expect( + pair.v2.stopBackgroundTask({ ...input, taskId: v2Running.taskId }), + ).resolves.toBeUndefined(); + // Background-task calls require a live session on both engines. + await expect( + pair.v1.listBackgroundTasks({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.listBackgroundTasks({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); +}); + +// --- Print-policy parity ------------------------------------------------------ + +/** Fixture with a print background policy section layered on the agent config. */ +function printConfig(backgroundSection: string): string { + return `${AGENT_CONFIG_TOML}\n[background]\n${backgroundSection}\n`; +} + +describe('v1↔v2 print policy parity', () => { + it('steer mode (the default) keeps the run alive while tasks are pending', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_print_steer' }); + const input = { sessionId: 'session_parity_print_steer' } as const; + // Quiescent: finish immediately. + const [v1Idle, v2Idle] = await Promise.all([ + pair.v1.handlePrintMainTurnCompleted(input), + pair.v2.handlePrintMainTurnCompleted(input), + ]); + expect(v2Idle).toEqual(v1Idle); + expect(v1Idle).toBe('finish'); + // A pending background task: continue. waitForBackgroundTasksOnPrint is + // a no-op outside drain mode — it resolves with the task still running. + const v1Task = await startDetachedBackgroundTask(pair.v1, input.sessionId, 'sleep 30'); + const v2Task = await startDetachedBackgroundTask(pair.v2, input.sessionId, 'sleep 30'); + await Promise.all([v1Task.run, v2Task.run]); + const [v1Busy, v2Busy] = await Promise.all([ + pair.v1.handlePrintMainTurnCompleted(input), + pair.v2.handlePrintMainTurnCompleted(input), + ]); + expect(v2Busy).toEqual(v1Busy); + expect(v1Busy).toBe('continue'); + await Promise.all([ + pair.v1.waitForBackgroundTasksOnPrint(input), + pair.v2.waitForBackgroundTasksOnPrint(input), + ]); + const [v1StillRunning, v2StillRunning] = await Promise.all([ + pair.v1.listBackgroundTasks({ ...input, activeOnly: true }), + pair.v2.listBackgroundTasks({ ...input, activeOnly: true }), + ]); + expect(v1StillRunning).toHaveLength(1); + expect(v2StillRunning).toHaveLength(1); + // Once the task is gone the steer policy finishes. + await Promise.all([ + stopAndSettle(pair.v1, input.sessionId, v1Task.taskId), + stopAndSettle(pair.v2, input.sessionId, v2Task.taskId), + ]); + const [v1Done, v2Done] = await Promise.all([ + pair.v1.handlePrintMainTurnCompleted(input), + pair.v2.handlePrintMainTurnCompleted(input), + ]); + expect(v2Done).toEqual(v1Done); + expect(v1Done).toBe('finish'); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('drain mode waits for pending tasks and then finishes', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(printConfig('print_background_mode = "drain"')); + try { + await createOnBoth(pair, { id: 'session_parity_print_drain' }); + const input = { sessionId: 'session_parity_print_drain' } as const; + // A short-lived detached task: the drain blocks until it completes. + const v1Task = await startDetachedBackgroundTask( + pair.v1, + input.sessionId, + 'sleep 0.3 && echo drain-done', + ); + const v2Task = await startDetachedBackgroundTask( + pair.v2, + input.sessionId, + 'sleep 0.3 && echo drain-done', + ); + await Promise.all([v1Task.run, v2Task.run]); + await Promise.all([ + pair.v1.waitForBackgroundTasksOnPrint(input), + pair.v2.waitForBackgroundTasksOnPrint(input), + ]); + // By the time the drain returns the task is terminal on both engines, + // with its terminal notification suppressed (same drain side effect). + const projectList = KNOWN_DIFFS.listBackgroundTasks; + const [v1Tasks, v2Tasks] = await Promise.all([ + pair.v1.listBackgroundTasks(input), + pair.v2.listBackgroundTasks(input), + ]); + expect(projectList(v2Tasks)).toEqual(projectList(v1Tasks)); + expect(v1Tasks).toHaveLength(1); + expect(v1Tasks[0]).toMatchObject({ + status: 'completed', + exitCode: 0, + terminalNotificationSuppressed: true, + }); + const [v1Output, v2Output] = await Promise.all([ + pair.v1.getBackgroundTaskOutput({ ...input, taskId: v1Task.taskId }), + pair.v2.getBackgroundTaskOutput({ ...input, taskId: v2Task.taskId }), + ]); + expect(v2Output).toEqual(v1Output); + expect(v1Output).toBe('drain-done\n'); + // With nothing left pending the policy finishes. + const [v1Done, v2Done] = await Promise.all([ + pair.v1.handlePrintMainTurnCompleted(input), + pair.v2.handlePrintMainTurnCompleted(input), + ]); + expect(v2Done).toEqual(v1Done); + expect(v1Done).toBe('finish'); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('keep_alive_on_exit = true keeps the legacy drain mapping', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(printConfig('keep_alive_on_exit = true')); + try { + await createOnBoth(pair, { id: 'session_parity_print_legacy' }); + const input = { sessionId: 'session_parity_print_legacy' } as const; + const v1Task = await startDetachedBackgroundTask(pair.v1, input.sessionId, 'sleep 0.3'); + const v2Task = await startDetachedBackgroundTask(pair.v2, input.sessionId, 'sleep 0.3'); + await Promise.all([v1Task.run, v2Task.run]); + // Steer would answer 'continue' here; the legacy mapping drains first + // and answers 'finish' with the task already terminal. + const [v1Done, v2Done] = await Promise.all([ + pair.v1.handlePrintMainTurnCompleted(input), + pair.v2.handlePrintMainTurnCompleted(input), + ]); + expect(v2Done).toEqual(v1Done); + expect(v1Done).toBe('finish'); + const [v1Active, v2Active] = await Promise.all([ + pair.v1.listBackgroundTasks({ ...input, activeOnly: true }), + pair.v2.listBackgroundTasks({ ...input, activeOnly: true }), + ]); + expect(v2Active).toEqual(v1Active); + expect(v1Active).toEqual([]); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('exit mode finishes immediately with tasks still running', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(printConfig('print_background_mode = "exit"')); + try { + await createOnBoth(pair, { id: 'session_parity_print_exit' }); + const input = { sessionId: 'session_parity_print_exit' } as const; + const v1Task = await startDetachedBackgroundTask(pair.v1, input.sessionId, 'sleep 30'); + const v2Task = await startDetachedBackgroundTask(pair.v2, input.sessionId, 'sleep 30'); + await Promise.all([v1Task.run, v2Task.run]); + const [v1Done, v2Done] = await Promise.all([ + pair.v1.handlePrintMainTurnCompleted(input), + pair.v2.handlePrintMainTurnCompleted(input), + ]); + expect(v2Done).toEqual(v1Done); + expect(v1Done).toBe('finish'); + // waitForBackgroundTasksOnPrint is a no-op too; the task is untouched. + await Promise.all([ + pair.v1.waitForBackgroundTasksOnPrint(input), + pair.v2.waitForBackgroundTasksOnPrint(input), + ]); + const [v1Active, v2Active] = await Promise.all([ + pair.v1.listBackgroundTasks({ ...input, activeOnly: true }), + pair.v2.listBackgroundTasks({ ...input, activeOnly: true }), + ]); + expect(v1Active).toHaveLength(1); + expect(v2Active).toHaveLength(1); + await Promise.all([ + stopAndSettle(pair.v1, input.sessionId, v1Task.taskId), + stopAndSettle(pair.v2, input.sessionId, v2Task.taskId), + ]); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('steer mode finishes at the max-turns cap and the wall-clock ceiling', async () => { + const restoreEnv = scrubConfigEnv(); + const maxTurnsPair = await makeAgentParityPair(printConfig('print_max_turns = 1')); + const ceilingPair = await makeAgentParityPair(printConfig('print_wait_ceiling_s = 1')); + try { + // print_max_turns = 1: the first completed turn may continue, the + // second crosses the cap. + await createOnBoth(maxTurnsPair, { id: 'session_parity_print_maxturns' }); + const maxTurnsInput = { sessionId: 'session_parity_print_maxturns' } as const; + const v1MaxTask = await startDetachedBackgroundTask( + maxTurnsPair.v1, + maxTurnsInput.sessionId, + 'sleep 30', + ); + const v2MaxTask = await startDetachedBackgroundTask( + maxTurnsPair.v2, + maxTurnsInput.sessionId, + 'sleep 30', + ); + await Promise.all([v1MaxTask.run, v2MaxTask.run]); + const [v1First, v2First] = await Promise.all([ + maxTurnsPair.v1.handlePrintMainTurnCompleted(maxTurnsInput), + maxTurnsPair.v2.handlePrintMainTurnCompleted(maxTurnsInput), + ]); + expect(v2First).toEqual(v1First); + expect(v1First).toBe('continue'); + const [v1Second, v2Second] = await Promise.all([ + maxTurnsPair.v1.handlePrintMainTurnCompleted(maxTurnsInput), + maxTurnsPair.v2.handlePrintMainTurnCompleted(maxTurnsInput), + ]); + expect(v2Second).toEqual(v1Second); + expect(v1Second).toBe('finish'); + await Promise.all([ + stopAndSettle(maxTurnsPair.v1, maxTurnsInput.sessionId, v1MaxTask.taskId), + stopAndSettle(maxTurnsPair.v2, maxTurnsInput.sessionId, v2MaxTask.taskId), + ]); + // print_wait_ceiling_s = 1: the deadline armed by the first call is + // crossed by the second. + await createOnBoth(ceilingPair, { id: 'session_parity_print_ceiling' }); + const ceilingInput = { sessionId: 'session_parity_print_ceiling' } as const; + const v1CeilingTask = await startDetachedBackgroundTask( + ceilingPair.v1, + ceilingInput.sessionId, + 'sleep 30', + ); + const v2CeilingTask = await startDetachedBackgroundTask( + ceilingPair.v2, + ceilingInput.sessionId, + 'sleep 30', + ); + await Promise.all([v1CeilingTask.run, v2CeilingTask.run]); + const [v1Before, v2Before] = await Promise.all([ + ceilingPair.v1.handlePrintMainTurnCompleted(ceilingInput), + ceilingPair.v2.handlePrintMainTurnCompleted(ceilingInput), + ]); + expect(v2Before).toEqual(v1Before); + expect(v1Before).toBe('continue'); + await new Promise((resolve) => setTimeout(resolve, 1100)); + const [v1After, v2After] = await Promise.all([ + ceilingPair.v1.handlePrintMainTurnCompleted(ceilingInput), + ceilingPair.v2.handlePrintMainTurnCompleted(ceilingInput), + ]); + expect(v2After).toEqual(v1After); + expect(v1After).toBe('finish'); + await Promise.all([ + stopAndSettle(ceilingPair.v1, ceilingInput.sessionId, v1CeilingTask.taskId), + stopAndSettle(ceilingPair.v2, ceilingInput.sessionId, v2CeilingTask.taskId), + ]); + // Print-policy calls require a live session on both engines. + await expect( + maxTurnsPair.v1.handlePrintMainTurnCompleted({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + maxTurnsPair.v2.handlePrintMainTurnCompleted({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + maxTurnsPair.v1.waitForBackgroundTasksOnPrint({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + maxTurnsPair.v2.waitForBackgroundTasksOnPrint({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(maxTurnsPair); + await closeSessionPair(ceilingPair); + restoreEnv(); + } + }); +}); + +// --------------------------------------------------------------------------- +// MCP parity. The global group drives isolated homes with the same mcp.json +// fixture written into each (a write through one engine must not be observed +// by the other mid-test); fixtures never declare reachable remote servers — +// the OAuth success path needs a real authorization server (network) and is +// registered as a provider boundary in the migration tracker, so only the +// pre-network guards and the flowId bookkeeping are compared. The stdio +// fixtures spawn local commands only. +// --------------------------------------------------------------------------- + +const MCP_STDIO_FIXTURE = join( + import.meta.dirname, + '../../agent-core/test/mcp/fixtures/mock-stdio-server.mjs', +); + +interface GlobalMcpParityPair { + readonly v1: SDKRpcClient; + readonly v2: SDKRpcClientV2; + readonly v1Home: HomePair; + readonly v2Home: HomePair; + readonly v1HomeDir: string; + readonly v2HomeDir: string; +} + +async function writeMcpJson(homeDir: string, value: unknown): Promise { + await writeFile(join(homeDir, 'mcp.json'), JSON.stringify(value), 'utf-8'); +} + +async function makeGlobalMcpParityPair(mcpJson?: unknown): Promise { + const v1HomeDir = await makeTempDir('kimi-sdk-parity-v1-home-'); + const v2HomeDir = await makeTempDir('kimi-sdk-parity-v2-home-'); + if (mcpJson !== undefined) { + await writeMcpJson(v1HomeDir, mcpJson); + await writeMcpJson(v2HomeDir, mcpJson); + } + return { + v1: new SDKRpcClient({ homeDir: v1HomeDir, identity: TEST_IDENTITY }), + v2: new SDKRpcClientV2({ homeDir: v2HomeDir, identity: TEST_IDENTITY }), + v1Home: { raw: v1HomeDir, real: await realpath(v1HomeDir) }, + v2Home: { raw: v2HomeDir, real: await realpath(v2HomeDir) }, + v1HomeDir, + v2HomeDir, + }; +} + +async function closeGlobalMcpPair(pair: GlobalMcpParityPair): Promise { + await pair.v1.close(); + await pair.v2.close(); +} + +/** The rejection of a call, or a test failure when the call resolves. */ +async function captureRejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + expect.unreachable('expected the call to reject'); +} + +/** + * Both engines must reject with the same code and the same message (home + * prefixes scrubbed — the file-store errors embed the mcp.json path). + */ +async function expectSameMcpRejection( + pair: GlobalMcpParityPair, + v1Call: (client: SDKRpcClient) => Promise, + v2Call: (client: SDKRpcClientV2) => Promise, +): Promise { + const [v1Error, v2Error] = await Promise.all([ + captureRejection(v1Call(pair.v1)), + captureRejection(v2Call(pair.v2)), + ]); + const payload = (error: unknown): unknown => { + const err = error as { code?: unknown; message?: unknown }; + return { code: err.code ?? null, message: String(err.message ?? error) }; + }; + expect(scrubHomePrefixes(payload(v2Error), pair.v2Home)).toEqual( + scrubHomePrefixes(payload(v1Error), pair.v1Home), + ); +} + +describe('v1↔v2 global MCP parity', () => { + it('CRUD round-trips identically and writes byte-identical mcp.json files', async () => { + const pair = await makeGlobalMcpParityPair({ + custom: { keep: true }, + mcpServers: { + 'existing-stdio': { command: 'existing-command' }, + 'existing-http': { + transport: 'http', + url: 'https://example.test/mcp', + auth: 'oauth', + }, + }, + }); + try { + const [v1Initial, v2Initial] = await Promise.all([ + pair.v1.listGlobalMcpServers(), + pair.v2.listGlobalMcpServers(), + ]); + expect(normalize(v2Initial, 'name')).toEqual(normalize(v1Initial, 'name')); + // The transport-less stdio entry parses with `transport: 'stdio'` + // injected; the `auth: 'oauth'` marker survives the round-trip. + expect(v1Initial).toEqual([ + { + name: 'existing-stdio', + transport: 'stdio', + command: 'existing-command', + }, + { + name: 'existing-http', + transport: 'http', + url: 'https://example.test/mcp', + auth: 'oauth', + }, + ]); + + const added: McpServerConfig = { + name: 'added', + transport: 'stdio', + command: 'added-command', + args: ['--flag'], + enabledTools: ['echo'], + }; + const [v1Added, v2Added] = await Promise.all([ + pair.v1.addGlobalMcpServer(added), + pair.v2.addGlobalMcpServer(added), + ]); + expect(normalize(v2Added, 'name')).toEqual(normalize(v1Added, 'name')); + + const updated: McpServerConfig = { + name: 'existing-http', + transport: 'sse', + url: 'https://example.test/sse', + headers: { 'x-fixture': 'yes' }, + }; + const [v1Updated, v2Updated] = await Promise.all([ + pair.v1.updateGlobalMcpServer(updated), + pair.v2.updateGlobalMcpServer(updated), + ]); + expect(normalize(v2Updated, 'name')).toEqual(normalize(v1Updated, 'name')); + + const [v1Removed, v2Removed] = await Promise.all([ + pair.v1.removeGlobalMcpServer('existing-stdio'), + pair.v2.removeGlobalMcpServer('existing-stdio'), + ]); + expect(normalize(v2Removed, 'name')).toEqual(normalize(v1Removed, 'name')); + expect(v1Removed.map((server) => server.name)).toEqual(['existing-http', 'added']); + + // The persisted files are byte-identical across the engines (same + // formatting, unrelated top-level content preserved). + const [v1File, v2File] = await Promise.all([ + readFile(join(pair.v1HomeDir, 'mcp.json'), 'utf-8'), + readFile(join(pair.v2HomeDir, 'mcp.json'), 'utf-8'), + ]); + expect(v2File).toBe(v1File); + } finally { + await closeGlobalMcpPair(pair); + } + }); + + it('CRUD rejections match on both engines', async () => { + const pair = await makeGlobalMcpParityPair({ + mcpServers: { existing: { command: 'existing-command' } }, + }); + try { + await expectSameMcpRejection( + pair, + (client) => + client.addGlobalMcpServer({ + name: 'existing', + transport: 'stdio', + command: 'duplicate', + }), + (client) => + client.addGlobalMcpServer({ + name: 'existing', + transport: 'stdio', + command: 'duplicate', + }), + ); + await expectSameMcpRejection( + pair, + (client) => + client.updateGlobalMcpServer({ name: 'missing', transport: 'stdio', command: 'x' }), + (client) => + client.updateGlobalMcpServer({ name: 'missing', transport: 'stdio', command: 'x' }), + ); + await expectSameMcpRejection( + pair, + (client) => client.addGlobalMcpServer({ name: ' ', transport: 'stdio', command: 'x' }), + (client) => client.addGlobalMcpServer({ name: ' ', transport: 'stdio', command: 'x' }), + ); + // Schema-invalid entry (neither command nor url): same schema on both + // sides, so the config.invalid message matches too. + await expectSameMcpRejection( + pair, + (client) => + client.addGlobalMcpServer({ name: 'invalid' } as never), + (client) => + client.addGlobalMcpServer({ name: 'invalid' } as never), + ); + // Removing an unknown name is NOT an error on either engine — the + // unchanged list comes back. + const [v1Removed, v2Removed] = await Promise.all([ + pair.v1.removeGlobalMcpServer('missing'), + pair.v2.removeGlobalMcpServer('missing'), + ]); + expect(normalize(v2Removed, 'name')).toEqual(normalize(v1Removed, 'name')); + expect(v1Removed).toHaveLength(1); + } finally { + await closeGlobalMcpPair(pair); + } + }); + + it('a malformed mcp.json rejects every read with the same config.invalid', async () => { + const pair = await makeGlobalMcpParityPair(); + await writeFile(join(pair.v1HomeDir, 'mcp.json'), '{ not valid json', 'utf-8'); + await writeFile(join(pair.v2HomeDir, 'mcp.json'), '{ not valid json', 'utf-8'); + try { + await expectSameMcpRejection( + pair, + (client) => client.listGlobalMcpServers(), + (client) => client.listGlobalMcpServers(), + ); + } finally { + await closeGlobalMcpPair(pair); + } + }); + + it('OAuth guards and flow bookkeeping match without contacting a server', async () => { + const pair = await makeGlobalMcpParityPair({ + mcpServers: { + stdio: { command: 'local-command' }, + 'bearer-remote': { + transport: 'http', + url: 'https://example.test/mcp', + bearerTokenEnvVar: 'FIXTURE_MCP_TOKEN', + }, + 'headers-remote': { + transport: 'http', + url: 'https://example.test/mcp', + headers: { authorization: 'Bearer static' }, + }, + 'plain-remote': { transport: 'http', url: 'https://example.test/mcp' }, + }, + }); + try { + // begin: unknown server / non-remote / static token / static headers + // all reject before any network I/O. + await expectSameMcpRejection( + pair, + (client) => client.beginGlobalMcpServerAuth('missing'), + (client) => client.beginGlobalMcpServerAuth('missing'), + ); + await expectSameMcpRejection( + pair, + (client) => client.beginGlobalMcpServerAuth('stdio'), + (client) => client.beginGlobalMcpServerAuth('stdio'), + ); + await expectSameMcpRejection( + pair, + (client) => client.beginGlobalMcpServerAuth('bearer-remote'), + (client) => client.beginGlobalMcpServerAuth('bearer-remote'), + ); + await expectSameMcpRejection( + pair, + (client) => client.beginGlobalMcpServerAuth('headers-remote'), + (client) => client.beginGlobalMcpServerAuth('headers-remote'), + ); + // reset: unknown / non-remote reject; a plain remote invalidates + // (no stored credentials — a no-op on both engines). + await expectSameMcpRejection( + pair, + (client) => client.resetGlobalMcpServerAuth('missing'), + (client) => client.resetGlobalMcpServerAuth('missing'), + ); + await expectSameMcpRejection( + pair, + (client) => client.resetGlobalMcpServerAuth('stdio'), + (client) => client.resetGlobalMcpServerAuth('stdio'), + ); + await Promise.all([ + pair.v1.resetGlobalMcpServerAuth('plain-remote'), + pair.v2.resetGlobalMcpServerAuth('plain-remote'), + ]); + // Flow bookkeeping: an unknown flowId rejects on complete and is a + // silent no-op on cancel, on both engines. + await expectSameMcpRejection( + pair, + (client) => client.completeGlobalMcpServerAuth({ flowId: 'flow_missing' }), + (client) => client.completeGlobalMcpServerAuth({ flowId: 'flow_missing' }), + ); + await Promise.all([ + pair.v1.cancelGlobalMcpServerAuth('flow_missing'), + pair.v2.cancelGlobalMcpServerAuth('flow_missing'), + ]); + } finally { + await closeGlobalMcpPair(pair); + } + }); + + it('testGlobalMcpServer reports the same probe results', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeGlobalMcpParityPair({ + mcpServers: { + working: { command: process.execPath, args: [MCP_STDIO_FIXTURE] }, + missing: { command: '/definitely/not/a/real/mcp-executable' }, + }, + }); + try { + await expectSameMcpRejection( + pair, + (client) => client.testGlobalMcpServer('unknown-server'), + (client) => client.testGlobalMcpServer('unknown-server'), + ); + const [v1Working, v2Working] = await Promise.all([ + pair.v1.testGlobalMcpServer('working'), + pair.v2.testGlobalMcpServer('working'), + ]); + expect(v2Working).toEqual(v1Working); + expect(v1Working.success).toBe(true); + expect(v1Working.output).toContain('Available tools: 3'); + const [v1Missing, v2Missing] = await Promise.all([ + pair.v1.testGlobalMcpServer('missing'), + pair.v2.testGlobalMcpServer('missing'), + ]); + expect(v2Missing).toEqual(v1Missing); + expect(v1Missing.success).toBe(false); + expect(v1Missing.output).toMatch(/ENOENT|not found|spawn/i); + } finally { + await closeGlobalMcpPair(pair); + restoreEnv(); + } + }, 20_000); +}); + +describe('v1↔v2 session MCP parity', () => { + /** working (connects, 3 tools) + broken (fails fast) + off (disabled). */ + const SESSION_MCP_FIXTURE = { + mcpServers: { + working: { command: process.execPath, args: [MCP_STDIO_FIXTURE] }, + broken: { command: '/definitely/not/a/real/mcp-executable' }, + off: { command: process.execPath, args: [MCP_STDIO_FIXTURE], enabled: false }, + }, + }; + + async function makeSessionMcpPair(mcpJson?: unknown): Promise { + const pair = await makeSessionParityPair(); + if (mcpJson !== undefined) { + await writeMcpJson(pair.v1Home.raw, mcpJson); + await writeMcpJson(pair.v2Home.raw, mcpJson); + } + return pair; + } + + it('listMcpServers reports the same entries after the initial connect', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionMcpPair(SESSION_MCP_FIXTURE); + try { + await createOnBoth(pair, { id: 'session_parity_mcp_list' }); + const input = { sessionId: 'session_parity_mcp_list' } as const; + const [v1Servers, v2Servers] = await Promise.all([ + pair.v1.listMcpServers(input), + pair.v2.listMcpServers(input), + ]); + expect(normalize(v2Servers, 'name')).toEqual(normalize(v1Servers, 'name')); + const byName = new Map(v1Servers.map((server) => [server.name, server])); + expect(byName.get('working')).toMatchObject({ + transport: 'stdio', + status: 'connected', + toolCount: 3, + }); + expect(byName.get('broken')).toMatchObject({ status: 'failed', toolCount: 0 }); + expect(byName.get('off')).toMatchObject({ status: 'disabled', toolCount: 0 }); + // An empty-config session lists nothing on either engine. + const emptyPair = await makeSessionMcpPair(); + try { + await createOnBoth(emptyPair, { id: 'session_parity_mcp_empty' }); + const emptyInput = { sessionId: 'session_parity_mcp_empty' } as const; + const [v1Empty, v2Empty] = await Promise.all([ + emptyPair.v1.listMcpServers(emptyInput), + emptyPair.v2.listMcpServers(emptyInput), + ]); + expect(v2Empty).toEqual(v1Empty); + expect(v1Empty).toEqual([]); + // Session-level reads require a live session on both engines. + await expect( + emptyPair.v1.listMcpServers({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + emptyPair.v2.listMcpServers({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(emptyPair); + } + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }, 20_000); + + it('getMcpStartupMetrics agrees, exactly 0 without servers', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionMcpPair(SESSION_MCP_FIXTURE); + try { + await createOnBoth(pair, { id: 'session_parity_mcp_metrics' }); + const input = { sessionId: 'session_parity_mcp_metrics' } as const; + const [v1Metrics, v2Metrics] = await Promise.all([ + pair.v1.getMcpStartupMetrics(input), + pair.v2.getMcpStartupMetrics(input), + ]); + const project = KNOWN_DIFFS.getMcpStartupMetrics; + expect(project(v2Metrics)).toEqual(project(v1Metrics)); + expect(v1Metrics.durationMs).toBeGreaterThanOrEqual(0); + expect(v2Metrics.durationMs).toBeGreaterThanOrEqual(0); + + const emptyPair = await makeSessionMcpPair(); + try { + await createOnBoth(emptyPair, { id: 'session_parity_mcp_metrics_empty' }); + const emptyInput = { sessionId: 'session_parity_mcp_metrics_empty' } as const; + const [v1Empty, v2Empty] = await Promise.all([ + emptyPair.v1.getMcpStartupMetrics(emptyInput), + emptyPair.v2.getMcpStartupMetrics(emptyInput), + ]); + expect(v2Empty).toEqual(v1Empty); + expect(v1Empty).toEqual({ durationMs: 0 }); + await expect( + emptyPair.v1.getMcpStartupMetrics({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + emptyPair.v2.getMcpStartupMetrics({ sessionId: 'session_missing' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(emptyPair); + } + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }, 20_000); + + it('reconnectMcpServer rejects and reconnects identically', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionMcpPair(SESSION_MCP_FIXTURE); + try { + await createOnBoth(pair, { id: 'session_parity_mcp_reconnect' }); + const input = { sessionId: 'session_parity_mcp_reconnect' } as const; + const [v1Unknown, v2Unknown] = await Promise.all([ + captureRejection(pair.v1.reconnectMcpServer({ ...input, name: 'missing' })), + captureRejection(pair.v2.reconnectMcpServer({ ...input, name: 'missing' })), + ]); + expect(v2Unknown).toMatchObject({ code: 'mcp.server_not_found' }); + expect((v1Unknown as Error).message).toBe((v2Unknown as Error).message); + const [v1Disabled, v2Disabled] = await Promise.all([ + captureRejection(pair.v1.reconnectMcpServer({ ...input, name: 'off' })), + captureRejection(pair.v2.reconnectMcpServer({ ...input, name: 'off' })), + ]); + expect(v2Disabled).toMatchObject({ code: 'mcp.server_disabled' }); + expect((v1Disabled as Error).message).toBe((v2Disabled as Error).message); + // Reconnecting the working server re-settles as connected with its + // tools rediscovered, on both engines. + await Promise.all([ + pair.v1.reconnectMcpServer({ ...input, name: 'working' }), + pair.v2.reconnectMcpServer({ ...input, name: 'working' }), + ]); + const [v1Servers, v2Servers] = await Promise.all([ + pair.v1.listMcpServers(input), + pair.v2.listMcpServers(input), + ]); + expect(normalize(v2Servers, 'name')).toEqual(normalize(v1Servers, 'name')); + await expect( + pair.v1.reconnectMcpServer({ sessionId: 'session_missing', name: 'working' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + await expect( + pair.v2.reconnectMcpServer({ sessionId: 'session_missing', name: 'working' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }, 20_000); +}); + +// --------------------------------------------------------------------------- +// Event & approval/question parity +// +// The v2 client forwards the engine's per-agent event buses into the base +// class's event listeners (translated back to the v1 `Event` shape) and +// bridges the interaction kernel's pending approvals / questions to the +// registered handlers. The v1 side of the approval/question cases drives the +// base class's `requestApproval` / `requestQuestion` directly — the same +// entry point the v1 engine's push RPC lands on — because a real turn's +// approval needs a model; the v2 side parks a real pending interaction in the +// live session's kernel through the engine accessor. No provider calls. +// --------------------------------------------------------------------------- + +/** Project a captured event stream down to the comparable per-type shape. */ +function projectEventStream(events: readonly Event[], sessionId: string): unknown[] { + return events + .filter((event) => event.sessionId === sessionId) + .flatMap((event) => { + switch (event.type) { + // Pinned: emission timing differs by design — v1 emits on its eager + // agent lifecycle points (mostly before a test's listeners attach), + // v2 on the lazy main-agent bind (after); the v2 payload is also a + // narrower slice (no phase/permission/contextUsage). The type maps + // 1:1 and is forwarded; full content parity is out of scope. + case 'agent.status.updated': + return []; + case 'shell.output': + return { + type: event.type, + commandId: event.commandId, + kind: event.update.kind, + text: event.update.text, + }; + case 'shell.started': + // taskId embeds a per-engine random suffix; only presence compares. + return { type: event.type, commandId: event.commandId, hasTaskId: true }; + case 'shell.completed': + return { + type: event.type, + commandId: event.commandId, + isError: event.isError, + hasTaskId: event.taskId !== undefined, + }; + case 'turn.started': + return { type: event.type, originKind: event.origin.kind }; + case 'turn.ended': + return { type: event.type, reason: event.reason, errorCode: event.error?.code }; + case 'session.meta.updated': + return { type: event.type, title: event.title, lastPrompt: event.patch?.['lastPrompt'] }; + case 'error': + return { type: event.type, code: event.code, message: event.message }; + default: + return { type: event.type }; + } + }); +} + +/** + * The `shell.*` slice of a captured stream without `shell.completed`: the v1 + * engine has no emission site for it anywhere (grep agent-core), while v2 + * fires it when a foreground command settles — a union-legal event the v2 + * stream is simply richer by. The tests assert its v2 presence explicitly + * and compare the started/output sequence v1 actually produces. + */ +function projectShellStream(events: readonly Event[], sessionId: string): unknown[] { + return projectEventStream(events, sessionId).filter( + (projected) => (projected as { type: string }).type !== 'shell.completed', + ); +} + +describe('v1↔v2 event & interaction parity', () => { + it('forwards the same shell.* event sequence for a `!` command', async () => { + const restoreEnv = scrubConfigEnv(); + // The configured home keeps v1's builtin tools assembled (the model-less + // v1 session answers "Bash tool is not available." — pinned in batch 4b). + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_events_shell' }); + const input = { sessionId: 'session_parity_events_shell' } as const; + const v1Events: Event[] = []; + const v2Events: Event[] = []; + pair.v1.onEvent((event) => v1Events.push(event)); + pair.v2.onEvent((event) => v2Events.push(event)); + await Promise.all([ + pair.v1.runShellCommand({ ...input, command: 'echo out; echo err >&2', commandId: 'cmd-ev' }), + pair.v2.runShellCommand({ ...input, command: 'echo out; echo err >&2', commandId: 'cmd-ev' }), + ]); + const v1Projected = projectShellStream(v1Events, input.sessionId); + const v2Projected = projectShellStream(v2Events, input.sessionId); + expect(v2Projected).toEqual(v1Projected); + // The compared surface: started, the two stream chunks (v1 never emits + // shell.completed — see projectShellStream). + expect(v1Projected).toEqual([ + { type: 'shell.started', commandId: 'cmd-ev', hasTaskId: true }, + { type: 'shell.output', commandId: 'cmd-ev', kind: 'stdout', text: 'out\n' }, + { type: 'shell.output', commandId: 'cmd-ev', kind: 'stderr', text: 'err\n' }, + ]); + // Pinned richer-v2 behavior: the settle event v1 never fires IS + // forwarded on the v2 stream (union-legal, asserted explicitly). + const v2Completed = projectEventStream(v2Events, input.sessionId).filter( + (projected) => (projected as { type: string }).type === 'shell.completed', + ); + expect(v2Completed).toEqual([ + { type: 'shell.completed', commandId: 'cmd-ev', isError: false, hasTaskId: true }, + ]); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('keeps exactly one event subscription per session across a reload', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeAgentParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_events_rewire' }); + const input = { sessionId: 'session_parity_events_rewire' } as const; + const v1Events: Event[] = []; + const v2Events: Event[] = []; + pair.v1.onEvent((event) => v1Events.push(event)); + pair.v2.onEvent((event) => v2Events.push(event)); + await Promise.all([ + pair.v1.runShellCommand({ ...input, command: 'echo one', commandId: 'cmd-one' }), + pair.v2.runShellCommand({ ...input, command: 'echo one', commandId: 'cmd-one' }), + ]); + await Promise.all([pair.v1.reloadSession(input), pair.v2.reloadSession(input)]); + await Promise.all([ + pair.v1.runShellCommand({ ...input, command: 'echo two', commandId: 'cmd-two' }), + pair.v2.runShellCommand({ ...input, command: 'echo two', commandId: 'cmd-two' }), + ]); + const v1Projected = projectShellStream(v1Events, input.sessionId); + const v2Projected = projectShellStream(v2Events, input.sessionId); + expect(v2Projected).toEqual(v1Projected); + // A wiring leaked across the reload would double the second command's + // events; exactly one started per command proves the close-path + // teardown. + expect( + v2Projected.filter((projected) => (projected as { type: string }).type === 'shell.started'), + ).toHaveLength(2); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('forwards the same event sequence for a model-less prompt failure', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_events_prompt' }); + const input = { sessionId: 'session_parity_events_prompt' } as const; + const v1Events: Event[] = []; + const v2Events: Event[] = []; + pair.v1.onEvent((event) => v1Events.push(event)); + pair.v2.onEvent((event) => v2Events.push(event)); + await Promise.all([ + pair.v1.prompt({ ...input, input: [{ type: 'text', text: 'hello events' }] }), + pair.v2.prompt({ ...input, input: [{ type: 'text', text: 'hello events' }] }), + ]); + await settleTurns(); + // Two pinned engine-internal differences in the failure path, both + // projected out at the call site: v2 interrupts the in-flight step + // before ending the turn (`turn.step.interrupted`) where v1's turn + // fails before the first step exists, and the trailing `error` event + // carries the same code but different message wording (v1: the + // login-guided 'LLM not set' text; v2: 'Model not set' — the same + // pinned wording family as setModel / generateAgentsMd). + const projectFailure = (events: readonly Event[]): unknown[] => + projectEventStream(events, input.sessionId).flatMap((projected) => { + const entry = projected as { type: string; code?: string }; + if (entry.type === 'turn.step.interrupted') return []; + if (entry.type === 'error') return { type: entry.type, code: entry.code }; + return entry; + }); + const v1Projected = projectFailure(v1Events); + const v2Projected = projectFailure(v2Events); + expect(v2Projected).toEqual(v1Projected); + // Sanity: the prompt metadata update and the failed turn both + // surfaced on the v1 side, so the comparison is non-vacuous. + expect(v1Projected.map((event) => (event as { type: string }).type)).toEqual( + expect.arrayContaining(['session.meta.updated', 'turn.started', 'turn.ended']), + ); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('bridges a pending approval to the session handler with v1 semantics', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_events_approval' }); + const sessionId = 'session_parity_events_approval'; + const v2Session = pair.v2.engineAccessor.get(ISessionLifecycleService).get(sessionId); + expect(v2Session).toBeDefined(); + const v2Approvals = v2Session!.accessor.get(ISessionApprovalService); + const requestInput = { + turnId: 1, + toolCallId: 'tc-parity-approval', + toolName: 'Bash', + action: 'Run command', + display: { kind: 'generic', summary: 'Run command', detail: { command: 'ls' } } as const, + }; + + // Handler approves: the engine-side request resolves with the handler's + // response on both engines, and the handler observes the same request. + const observed: { v1?: unknown; v2?: unknown } = {}; + const handler = + (sink: 'v1' | 'v2') => + (request: ApprovalRequest): ApprovalResponse => { + observed[sink] = request; + return { decision: 'approved', scope: 'session' }; + }; + pair.v1.setApprovalHandler(sessionId, handler('v1')); + pair.v2.setApprovalHandler(sessionId, handler('v2')); + const [v1Response, v2Response] = await Promise.all([ + pair.v1.requestApproval({ ...requestInput, sessionId, agentId: 'main' }), + v2Approvals.request({ ...requestInput, sessionId, agentId: 'main' }), + ]); + expect(v2Response).toEqual(v1Response); + expect(v1Response).toEqual({ decision: 'approved', scope: 'session' }); + expect(observed.v2).toEqual(observed.v1); + expect(v2Approvals.listPending()).toEqual([]); + + // No handler: both cancel with the same decision and feedback. + pair.v1.setApprovalHandler(sessionId, undefined); + pair.v2.setApprovalHandler(sessionId, undefined); + const [v1Unanswered, v2Unanswered] = await Promise.all([ + pair.v1.requestApproval({ ...requestInput, toolCallId: 'tc-2', sessionId, agentId: 'main' }), + v2Approvals.request({ ...requestInput, toolCallId: 'tc-2', sessionId, agentId: 'main' }), + ]); + expect(v2Unanswered).toEqual(v1Unanswered); + expect(v1Unanswered).toEqual({ + decision: 'cancelled', + feedback: 'No approval handler registered.', + }); + + // Handler throws: both cancel with the same feedback and emit the same + // error event into the session's event stream. + const v1Events: Event[] = []; + const v2Events: Event[] = []; + pair.v1.onEvent((event) => v1Events.push(event)); + pair.v2.onEvent((event) => v2Events.push(event)); + const failing = (): never => { + throw new Error('handler boom'); + }; + pair.v1.setApprovalHandler(sessionId, failing); + pair.v2.setApprovalHandler(sessionId, failing); + const [v1Failed, v2Failed] = await Promise.all([ + pair.v1.requestApproval({ ...requestInput, toolCallId: 'tc-3', sessionId, agentId: 'main' }), + v2Approvals.request({ ...requestInput, toolCallId: 'tc-3', sessionId, agentId: 'main' }), + ]); + expect(v2Failed).toEqual(v1Failed); + expect(v1Failed).toEqual({ decision: 'cancelled', feedback: 'Approval handler failed.' }); + const v1Errors = projectEventStream(v1Events, sessionId); + const v2Errors = projectEventStream(v2Events, sessionId); + expect(v2Errors).toEqual(v1Errors); + expect(v1Errors).toEqual([ + { type: 'error', code: 'session.approval_handler_error', message: 'handler boom' }, + ]); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); + + it('bridges a pending question to the session handler with v1 semantics', async () => { + const restoreEnv = scrubConfigEnv(); + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_events_question' }); + const sessionId = 'session_parity_events_question'; + const v2Session = pair.v2.engineAccessor.get(ISessionLifecycleService).get(sessionId); + expect(v2Session).toBeDefined(); + const v2Questions = v2Session!.accessor.get(ISessionQuestionService); + const requestInput = { + turnId: 1, + toolCallId: 'tc-parity-question', + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [{ label: 'a', description: 'first' }, { label: 'b' }], + multiSelect: false, + }, + ], + }; + + // Handler answers: the engine-side request resolves with the handler's + // answers on both engines, and the handler observes the same request. + const observed: { v1?: unknown; v2?: unknown } = {}; + const handler = + (sink: 'v1' | 'v2') => + (request: QuestionRequest): QuestionResult => { + observed[sink] = request; + return { answers: { 'Pick one': 'a' }, method: 'enter' }; + }; + pair.v1.setQuestionHandler(sessionId, handler('v1')); + pair.v2.setQuestionHandler(sessionId, handler('v2')); + const [v1Result, v2Result] = await Promise.all([ + pair.v1.requestQuestion({ ...requestInput, sessionId, agentId: 'main' }), + v2Questions.request({ ...requestInput }, { agentId: 'main' }), + ]); + expect(v2Result).toEqual(v1Result); + expect(v1Result).toEqual({ answers: { 'Pick one': 'a' }, method: 'enter' }); + expect(observed.v2).toEqual(observed.v1); + expect(v2Questions.listPending()).toEqual([]); + + // No handler: both answer null (the dismissed outcome on both engines). + pair.v1.setQuestionHandler(sessionId, undefined); + pair.v2.setQuestionHandler(sessionId, undefined); + const [v1Dismissed, v2Dismissed] = await Promise.all([ + pair.v1.requestQuestion({ ...requestInput, toolCallId: 'tc-2', sessionId, agentId: 'main' }), + v2Questions.request({ ...requestInput, toolCallId: 'tc-2' }, { agentId: 'main' }), + ]); + expect(v2Dismissed).toEqual(v1Dismissed); + expect(v1Dismissed).toBeNull(); + + // Handler throws: both answer null and emit the same error event. + const v1Events: Event[] = []; + const v2Events: Event[] = []; + pair.v1.onEvent((event) => v1Events.push(event)); + pair.v2.onEvent((event) => v2Events.push(event)); + const failing = (): never => { + throw new Error('question boom'); + }; + pair.v1.setQuestionHandler(sessionId, failing); + pair.v2.setQuestionHandler(sessionId, failing); + const [v1Failed, v2Failed] = await Promise.all([ + pair.v1.requestQuestion({ ...requestInput, toolCallId: 'tc-3', sessionId, agentId: 'main' }), + v2Questions.request({ ...requestInput, toolCallId: 'tc-3' }, { agentId: 'main' }), + ]); + expect(v2Failed).toEqual(v1Failed); + expect(v1Failed).toBeNull(); + const v1Errors = projectEventStream(v1Events, sessionId); + const v2Errors = projectEventStream(v2Events, sessionId); + expect(v2Errors).toEqual(v1Errors); + expect(v1Errors).toEqual([ + { type: 'error', code: 'session.question_handler_error', message: 'question boom' }, + ]); + } finally { + await closeSessionPair(pair); + restoreEnv(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Residual surface parity (exportSession / startBtw / swarm mode / listSkills) +// +// The last migrated methods, driven through `SDKRpcClient` / `SDKRpcClientV2` +// directly like the other session batches. No provider calls: export reads +// persisted state, btw/swarm are context-only, and the `swarm()` case uses the +// model-less prompt failure path (its turn start/end is what drives the +// task-trigger auto-exit on both engines). +// --------------------------------------------------------------------------- + +/** Poll a condition until it holds or the budget runs out (engine-async settle). */ +async function waitForCondition(check: () => Promise, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return check(); +} + +describe('v1↔v2 residual surface parity', () => { + it('exportSession packages the same entry list and manifest on both engines', async () => { + const pair = await makeSessionParityPair(); + const outDir = await makeTempDir('kimi-sdk-parity-export-'); + try { + await createOnBoth(pair, { id: 'session_parity_export' }); + const sessionId = 'session_parity_export'; + // Give both sessions the same persisted wire activity to package. + await Promise.all([ + pair.v1.importContext({ sessionId, content: 'export parity context', source: 'parity' }), + pair.v2.importContext({ sessionId, content: 'export parity context', source: 'parity' }), + ]); + const input = { + id: sessionId, + version: '1.2.3', + installSource: 'parity-install', + shellEnv: { term: 'xterm', shell: '/bin/zsh' }, + } as const; + const [v1Result, v2Result] = await Promise.all([ + pair.v1.exportSession({ ...input, outputPath: join(outDir, 'v1.zip') }), + pair.v2.exportSession({ ...input, outputPath: join(outDir, 'v2.zip') }), + ]); + // The zip entry list IS part of the parity surface: both engines lay + // the session directory out as state.json + agents/main/wire.jsonl. + expect(normalize(v2Result.entries, '')).toEqual(normalize(v1Result.entries, '')); + expect(normalize(v1Result.entries, '')).toEqual([ + 'agents/main/wire.jsonl', + 'manifest.json', + 'state.json', + ]); + const project = KNOWN_DIFFS.exportSession; + expect(project(v2Result, pair.v2Home)).toEqual(project(v1Result, pair.v1Home)); + // Explicit assertions for the projected fields (see KNOWN_DIFFS): + // per-run stamps differ, each engine reports its own wire version, and + // only v2's scanner finds the per-agent wire (v1 scans a stale root + // path and reports no activity — the pinned v1-side defect). + expect(typeof v1Result.manifest.exportedAt).toBe('string'); + expect(typeof v2Result.manifest.exportedAt).toBe('string'); + expect(v1Result.manifest.wireProtocolVersion).not.toBe( + v2Result.manifest.wireProtocolVersion, + ); + expect(v1Result.manifest.sessionFirstActivity).toBeUndefined(); + expect(typeof v2Result.manifest.sessionFirstActivity).toBe('string'); + // The archives actually landed on disk, non-empty. + const v1Zip = await readFile(v1Result.zipPath); + const v2Zip = await readFile(v2Result.zipPath); + expect(v1Zip.byteLength).toBeGreaterThan(0); + expect(v2Zip.byteLength).toBeGreaterThan(0); + } finally { + await closeSessionPair(pair); + } + }); + + it('exportSession rejects an unknown session with SESSION_NOT_FOUND on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + const input = { id: 'session_missing', version: '1.2.3' } as const; + // Same code; the message wording differs (v1 store vs v2 index). + await expect(pair.v1.exportSession(input)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + message: 'Session "session_missing" was not found', + }); + await expect(pair.v2.exportSession(input)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_NOT_FOUND, + message: 'Session "session_missing" does not exist', + }); + } finally { + await closeSessionPair(pair); + } + }); + + it('exportSession validates the host version only on v2 (pinned gap)', async () => { + const pair = await makeSessionParityPair(); + const outDir = await makeTempDir('kimi-sdk-parity-export-'); + try { + await createOnBoth(pair, { id: 'session_parity_export_version' }); + const input = { id: 'session_parity_export_version', version: ' ' } as const; + // v1 records the blank version unchecked; v2 rejects it + // (`session.export_missing_version`, a v2-only error code). + await expect( + pair.v1.exportSession({ ...input, outputPath: join(outDir, 'blank-version.zip') }), + ).resolves.toMatchObject({ + manifest: { kimiCodeVersion: ' ' }, + }); + await expect(pair.v2.exportSession(input)).rejects.toMatchObject({ + code: 'session.export_missing_version', + }); + } finally { + await closeSessionPair(pair); + } + }); + + it('startBtw forks a side-question child with the parent context on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_btw' }); + const sessionId = 'session_parity_btw'; + await Promise.all([ + pair.v1.importContext({ sessionId, content: 'btw parity context', source: 'parity' }), + pair.v2.importContext({ sessionId, content: 'btw parity context', source: 'parity' }), + ]); + const [v1ChildId, v2ChildId] = await Promise.all([ + pair.v1.startBtw({ sessionId }), + pair.v2.startBtw({ sessionId }), + ]); + // The return is the forked child's agent id — random per engine, so + // only its shape compares; the child's CONTEXT compares in full below. + expect(typeof v1ChildId).toBe('string'); + expect(v1ChildId.length).toBeGreaterThan(0); + expect(typeof v2ChildId).toBe('string'); + expect(v2ChildId.length).toBeGreaterThan(0); + const [v1Context, v2Context] = await Promise.all([ + pair.v1.withInteractiveAgent(v1ChildId, () => pair.v1.getContext({ sessionId })), + pair.v2.withInteractiveAgent(v2ChildId, () => pair.v2.getContext({ sessionId })), + ]); + // Inheritance gap, pinned: v1's btw child inherits through the + // model-facing `project()`, which strips every message's `origin`, + // while v2's fork appends the canonical messages verbatim (origin + // kept). The message CONTENT is identical on both — compare with the + // origins projected away. + const stripOrigins = (context: { readonly history: readonly unknown[] }): unknown => + JSON.parse( + JSON.stringify(context.history, (key, value: unknown) => + key === 'origin' ? undefined : value, + ), + ); + expect(stripOrigins(v2Context)).toEqual(stripOrigins(v1Context)); + // Non-vacuous: the inherited import plus the side-question reminder + // (byte-identical template on both engines). + const v1History = v1Context.history; + expect(v1History.length).toBeGreaterThanOrEqual(2); + const reminder = v1History.at(-1); + expect(JSON.stringify(reminder)).toContain('side-channel conversation'); + } finally { + await closeSessionPair(pair); + } + }); + + it('setSwarmMode toggles swarm mode with the same reminder lifecycle on both engines', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_swarm' }); + const input = { sessionId: 'session_parity_swarm' } as const; + const statusOnBoth = () => + Promise.all([pair.v1.getStatus(input), pair.v2.getStatus(input)]); + const historyOnBoth = () => + Promise.all([pair.v1.getContext(input), pair.v2.getContext(input)]); + + // Enter with the manual trigger: active on both, and the (byte-identical) + // enter reminder lands in the context on both. + await Promise.all([ + pair.v1.setSwarmMode({ ...input, enabled: true, trigger: 'manual' }), + pair.v2.setSwarmMode({ ...input, enabled: true, trigger: 'manual' }), + ]); + const [v1Active, v2Active] = await statusOnBoth(); + expect(v1Active.swarmMode).toBe(true); + expect(v2Active.swarmMode).toBe(true); + const project = KNOWN_DIFFS.getContext; + const [v1Entered, v2Entered] = await historyOnBoth(); + expect(project(v2Entered)).toEqual(project(v1Entered)); + expect(v1Entered.history).toHaveLength(1); + expect(JSON.stringify(v1Entered.history[0])).toContain(''); + + // Enter is idempotent on both: still one reminder, still active. + await Promise.all([ + pair.v1.setSwarmMode({ ...input, enabled: true, trigger: 'manual' }), + pair.v2.setSwarmMode({ ...input, enabled: true, trigger: 'manual' }), + ]); + const [v1Twice, v2Twice] = await historyOnBoth(); + expect(v1Twice.history).toHaveLength(1); + expect(v2Twice.history).toHaveLength(1); + + // Exit pops the reminder (it is the last message) on both. + await Promise.all([ + pair.v1.setSwarmMode({ ...input, enabled: false }), + pair.v2.setSwarmMode({ ...input, enabled: false }), + ]); + const [v1Inactive, v2Inactive] = await statusOnBoth(); + expect(v1Inactive.swarmMode).toBe(false); + expect(v2Inactive.swarmMode).toBe(false); + const [v1Exited, v2Exited] = await historyOnBoth(); + expect(project(v2Exited)).toEqual(project(v1Exited)); + expect(v1Exited.history).toHaveLength(0); + + // Exit is idempotent too: a second exit is a silent no-op on both. + await Promise.all([ + pair.v1.setSwarmMode({ ...input, enabled: false }), + pair.v2.setSwarmMode({ ...input, enabled: false }), + ]); + const [v1Idle, v2Idle] = await historyOnBoth(); + expect(v1Idle.history).toHaveLength(0); + expect(v2Idle.history).toHaveLength(0); + + // The `tool` trigger injects no reminder on either engine. + await Promise.all([ + pair.v1.setSwarmMode({ ...input, enabled: true, trigger: 'tool' }), + pair.v2.setSwarmMode({ ...input, enabled: true, trigger: 'tool' }), + ]); + const [v1Tool, v2Tool] = await statusOnBoth(); + expect(v1Tool.swarmMode).toBe(true); + expect(v2Tool.swarmMode).toBe(true); + const [v1ToolHistory, v2ToolHistory] = await historyOnBoth(); + expect(v1ToolHistory.history).toHaveLength(0); + expect(v2ToolHistory.history).toHaveLength(0); + await Promise.all([ + pair.v1.setSwarmMode({ ...input, enabled: false }), + pair.v2.setSwarmMode({ ...input, enabled: false }), + ]); + } finally { + await closeSessionPair(pair); + } + }); + + it('swarm() enters task-triggered swarm mode, prompts, and auto-exits after the turn', async () => { + const pair = await makeSessionParityPair(); + try { + await createOnBoth(pair, { id: 'session_parity_swarm_prompt' }); + const input = { sessionId: 'session_parity_swarm_prompt' } as const; + // The model-less prompt fails asynchronously on both engines; the turn + // still starts and ends, which is what drives the task-trigger + // auto-exit. `swarm()` itself resolves on both (v1's prompt RPC + // returns after launch; v2's after enqueue). + await Promise.all([ + pair.v1.swarm({ ...input, input: [{ type: 'text', text: 'parity swarm prompt' }] }), + pair.v2.swarm({ ...input, input: [{ type: 'text', text: 'parity swarm prompt' }] }), + ]); + const settled = await waitForCondition(async () => { + const [v1Status, v2Status] = await Promise.all([ + pair.v1.getStatus(input), + pair.v2.getStatus(input), + ]); + return v1Status.swarmMode === false && v2Status.swarmMode === false; + }); + expect(settled).toBe(true); + await settleTurns(); + } finally { + await closeSessionPair(pair); + } + }); + + it('listSkills returns the same merged catalog on the same fixture', async () => { + const pair = await makeSessionParityPair(); + try { + // Same fixture on both sides: a user skill in each home (scrubbed to + // `` by the projection) and a project skill in the shared workDir. + await writeSkill(join(pair.v1Home.raw, 'skills', 'parity-user-skill'), 'parity-user-skill'); + await writeSkill(join(pair.v2Home.raw, 'skills', 'parity-user-skill'), 'parity-user-skill'); + await writeSkill( + join(pair.workDir, '.kimi-code', 'skills', 'parity-project-skill'), + 'parity-project-skill', + ); + await createOnBoth(pair, { id: 'session_parity_skills' }); + const [v1Skills, v2Skills] = await Promise.all([ + pair.v1.listSkills({ sessionId: 'session_parity_skills' }), + pair.v2.listSkills({ sessionId: 'session_parity_skills' }), + ]); + const project = KNOWN_DIFFS.listSkills; + expect(normalize(project(v2Skills, pair.v2Home), 'name')).toEqual( + normalize(project(v1Skills, pair.v1Home), 'name'), + ); + // Non-vacuous: both fixture skills are present on both engines. + for (const skills of [v1Skills, v2Skills]) { + const names = skills.map((skill) => skill.name); + expect(names).toContain('parity-user-skill'); + expect(names).toContain('parity-project-skill'); + } + } finally { + await closeSessionPair(pair); + } + }); +}); diff --git a/packages/node-sdk/tsconfig.dts.json b/packages/node-sdk/tsconfig.dts.json index c1210f3de..084b68a2a 100644 --- a/packages/node-sdk/tsconfig.dts.json +++ b/packages/node-sdk/tsconfig.dts.json @@ -13,7 +13,9 @@ "src/**/*.ts", "../agent-core/src/**/*.ts", "../agent-core/src/prompt-modules.d.ts", + "../agent-core-v2/src/**/*.ts", "../kaos/src/**/*.ts", + "../klient/src/**/*.ts", "../kosong/src/**/*.ts", "../oauth/src/**/*.ts" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a40f42f2d..84087eb6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -959,12 +959,18 @@ importers: '@moonshot-ai/agent-core': specifier: workspace:^ version: link:../agent-core + '@moonshot-ai/agent-core-v2': + specifier: workspace:^ + version: link:../agent-core-v2 '@moonshot-ai/kaos': specifier: workspace:^ version: link:../kaos '@moonshot-ai/kimi-code-oauth': specifier: workspace:^ version: link:../oauth + '@moonshot-ai/klient': + specifier: workspace:^ + version: link:../klient '@moonshot-ai/kosong': specifier: workspace:^ version: link:../kosong @@ -13679,7 +13685,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: