feat(node-sdk): migrate the SDK method surface to agent-core-v2 (#2262)

* feat(node-sdk): add agent-core-v2 backed SDKRpcClientV2 harness

- add SDKRpcClientV2 wiring the v2 engine (DI x Scope) in-process via the
  klient memory transport, with getExperimentalFeatures migrated to
  klient.global.flags.list() and unmigrated methods failing fast
- export createKimiHarnessV2 / SDKRpcClientV2 from the SDK index
- wire the experimental v2 gate into the CLI interactive shell (run-shell)
- extend build-dts to bundle agent-core-v2 and klient declarations

* feat(node-sdk): migrate listWorkspaceSkills to agent-core-v2

- add engineAccessor escape hatch exposing the in-process engine's app-scope
  service accessor for SDK methods the klient facade does not cover yet
- implement listWorkspaceSkills via ISkillDiscovery plus the v2 user/project
  root helpers and BUILTIN_SKILLS (plugin skills and skillDirs still gaps)
- add a v1-v2 parity test pinning identical return values per migrated
  method, with understood gaps listed explicitly in KNOWN_DIFFS

* feat(node-sdk): migrate the SDK method surface to agent-core-v2

- implement the remaining SDKRpcClientBase methods on SDKRpcClientV2,
  routed through the klient facade where covered, the engineAccessor
  escape hatch where the engine has a service, or SDK-side rebuilds on
  v2 primitives where only primitives exist (config shape mapping,
  global mcp.json store, MCP OAuth flows, importContext, session
  warnings, print background policy)
- translate the v2 event stream into the v1 Event union and bridge
  approval/question/user_tool interactions per live session
- rebuild resume replay by folding the v2 wire.jsonl through the v1
  agent restore pipeline, so resumed sessions render history again
- keep deleteSession as not_implemented; the v2 engine has no delete
  capability
- extend the v1-v2 parity suite to every migrated method, pinning
  understood engine differences in KNOWN_DIFFS
- add the dev:cli:v2 root script to launch the TUI on the v2 engine

* fix(node-sdk): await the v2 undo and compaction-cancel agent calls

* test(cli): spread the real oauth module in the telemetry test mock

* feat(node-sdk): forward v2 engine telemetry to the host telemetry client

* feat(cli): gate the v2 TUI route behind a dedicated KIMI_CODE_TUI_V2 switch

* fix(node-sdk): honor skillDirs on the agent-core-v2 SDK route

The v2 SDK client accepted KimiHarnessOptions.skillDirs (the CLI's
--skills-dir) but never seeded it into the engine, so explicit skill
dirs were silently dropped on the v2 TUI route and the Skill tool
could not find skills from them. Seed skillCatalogRuntimeOptions at
bootstrap and let listWorkspaceSkills resolve the explicit dirs as
the user source, matching the engine's session skill catalog.

* refactor(cli): gate the v2 TUI route behind the master experimental flag again

Drop the dedicated KIMI_CODE_TUI_V2 switch: the TUI v2 harness route is
gated by KIMI_CODE_EXPERIMENTAL_FLAG, the same master switch as the
kimi -p v2 route. The gate tests are kept with updated assertions, and
dev:cli:v2 sets the master flag again.
This commit is contained in:
Haozhe 2026-07-28 20:29:45 +08:00 committed by GitHub
parent d88b3775c9
commit f79fde2b90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 7877 additions and 26 deletions

View file

@ -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';

View file

@ -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,

View file

@ -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<typeof import('@moonshot-ai/kimi-code-sdk')>();
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<string, string | undefined>, fn: () => Promise<void>): Promise<void> {
const saved: Record<string, string | undefined> = {};
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',

View file

@ -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<typeof import('@moonshot-ai/kimi-code-oauth')>();
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<typeof import('@moonshot-ai/kimi-code-sdk')>();

View file

@ -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",

View file

@ -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"

View file

@ -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,

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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<string, unknown>): KimiConfig {
const config: Record<string, unknown> = {};
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<string, unknown>;
readonly models: Record<string, unknown>;
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<string, unknown> | undefined;
readonly models: Record<string, Record<string, unknown>> | undefined;
readonly defaultModel: string | undefined;
readonly defaultProvider: string | undefined;
readonly providerId: string;
}): ProviderRemovalPlan {
const providers = { ...input.providers };
delete providers[input.providerId];
const models: Record<string, unknown> = {};
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,
};
}

View file

@ -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<string> = 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<Record<string, string>> = {
'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;
}

View file

@ -0,0 +1,250 @@
/**
* The v1 user-global MCP surface (`<KIMI_CODE_HOME>/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 (`<home>/credentials/mcp/<key>-*.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<string, unknown>;
readonly rawServers: Record<string, unknown>;
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<readonly GlobalMcpServerConfig[]> {
return (await this.read()).servers;
}
async get(name: string): Promise<GlobalMcpServerConfig> {
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<readonly GlobalMcpServerConfig[]> {
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<readonly GlobalMcpServerConfig[]> {
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<readonly GlobalMcpServerConfig[]> {
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<GlobalMcpConfigFile> {
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<string, unknown>,
): Promise<void> {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** Byte-identical with v1's `escapeXmlAttr` (& " only). */
function escapeXmlAttr(input: string): string {
return input.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}
/**
* 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:
`<system>The user has imported context from ${escapeXml(normalizedSource)}. ` +
`${IMPORT_CONTEXT_GUIDANCE}</system>`,
},
{
type: 'text',
text:
`<imported_context source="${escapeXmlAttr(normalizedSource)}">\n` +
`${content}\n</imported_context>`,
},
],
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,
},
},
);
}
}

View file

@ -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
* `<sessionDir>/agents/<agentId>/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<Record<string, unknown>>;
}
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<AgentRecord> {
for (const record of this.records) {
yield record;
}
}
append(_input: AgentRecord): void {}
rewrite(_records: readonly AgentRecord[]): void {}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
/**
* 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<FoldedAgentReplay> {
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;
}

View file

@ -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<string, unknown>,
};
}
function v2AgentsToV1(agents: Readonly<Record<string, V2AgentMeta>>): Record<string, AgentMeta> {
const mapped: Record<string, AgentMeta> = {};
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;
}

View file

@ -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<ApprovalResponse>;
requestQuestion(
request: QuestionRequest & { sessionId: string; agentId: string },
): Promise<QuestionResult>;
toolCall(request: ToolCallRequest): Promise<ToolCallResponse>;
}
/**
* 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<string, IDisposable>();
/** Pending interactions already handed to the sink (the kernel re-fires the full pending set on every change). */
private readonly bridgedInteractionIds = new Set<string>();
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<void> {
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<void> {
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<void> {
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.
}
}
}

View file

@ -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<void> {
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',
);
}

File diff suppressed because it is too large Load diff

View file

@ -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"
],

8
pnpm-lock.yaml generated
View file

@ -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: