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: