/** * Scenario: v2 wiring — the harness talks to the in-process agent-core-v2 * engine (klient memory transport) instead of the v1 KimiCore RPC pair. * Responsibilities: v2-client behaviors the v1↔v2 parity gate does not * compare (engine telemetry forwarding, host request headers, the Windows * Git Bash probe, workspace trust, the config write cascade, deleteSession, * foldAgentWireReplay). * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; remote provider calls are stubbed. * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts */ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { FileTokenStorage, resolveKimiCodeOAuthRef, resolveKimiTokenStorageName, } from '@moonshot-ai/kimi-code-oauth'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildDaemonFileUrl, createKimiHarness, ErrorCodes, isDaemonFileUrl, isKimiError, KimiHarness, removeProviderFromConfig, SDKRpcClientV2, toKimiErrorPayload, type Event, type KimiConfig, } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; import { drainQueryStoreDisposals, drainSessionIndexMirror, Error2, getLiveSessionById, HostProcessError, IAgentIdentity, IAgentTodoService, IAgentLifecycleService, IAgentProfileService, IAgentToolActivationService, IAgentToolRegistryService, INotifyUserTool, IAtomicDocumentStore, ISessionContext, IAgentTowerService, IHostRequestHeaders, IMcpManagementService, IMcpOAuthService, ISessionManager, OsProcessErrors, } from '@moonshot-ai/agent-core-v2'; import { McpOAuthService as McpOAuthServiceV2 } from '@moonshot-ai/agent-core-v2/mcpCore/oauth/service'; import { TEST_IDENTITY } from './test-identity'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; const hostEnvProbe = vi.hoisted(() => ({ failWithMissingShell: false })); vi.mock('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe', async (importOriginal) => { const actual = await importOriginal< typeof import('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe') >(); return { ...actual, probeHostEnvironmentFromNode: () => hostEnvProbe.failWithMissingShell ? Promise.reject( new actual.ProbeShellNotFoundError('Git Bash missing (stubbed)', [ 'C:\\Program Files\\Git\\bin\\bash.exe', ]), ) : actual.probeHostEnvironmentFromNode(), }; }); const tempDirs: string[] = []; afterEach(async () => { // The read-model mirror/query-store close asynchronously on dispose; await // the drains so the rm below never races their final flush (ENOTEMPTY). await drainSessionIndexMirror(); await drainQueryStoreDisposals(); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } }); function stubProcessPlatform(platform: NodeJS.Platform): () => void { const descriptor = Object.getOwnPropertyDescriptor(process, 'platform'); Object.defineProperty(process, 'platform', { value: platform, configurable: true }); return () => { if (descriptor !== undefined) { Object.defineProperty(process, 'platform', descriptor); } }; } async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); return { harness: createKimiHarness({ homeDir, identity: TEST_IDENTITY }), homeDir }; } /** Whether the persisted session directory exists under `/sessions//`. */ async function sessionDirExists(homeDir: string, sessionId: string): Promise { let buckets: readonly string[]; try { buckets = await readdir(join(homeDir, 'sessions')); } catch { return false; } for (const bucket of buckets) { try { await readdir(join(homeDir, 'sessions', bucket, sessionId)); return true; } catch { // Not under this bucket. } } return false; } describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => { it('exposes the validated runtime binding through Session', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); const session = await harness.createSession({ id: 'ses_runtime', workDir }); try { const binding = await session.getRuntime(); expect(binding.runtimeId).toBe('local'); expect(binding.workspaceId.length).toBeGreaterThan(0); await expect(session.switchRuntime('missing-runtime')).rejects.toThrow(/missing-runtime/); expect(await session.getRuntime()).toEqual(binding); } finally { await harness.close(); } }); it('reports global MCP authorization without probing when verify is false', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const implicitOAuthUrl = 'https://implicit-oauth.example.test/mcp'; const authorizedUrl = 'https://authorized.example.test/mcp'; const requiredUrl = 'https://required.example.test/mcp'; await writeFile( join(homeDir, 'mcp.json'), JSON.stringify({ mcpServers: { stdio: { command: 'local-command' }, plain: { transport: 'http', url: 'https://plain.example.test/mcp' }, detected: { transport: 'http', url: implicitOAuthUrl }, sse: { transport: 'sse', url: implicitOAuthUrl }, 'sse-oauth': { transport: 'sse', url: implicitOAuthUrl, auth: 'oauth' }, bearer: { transport: 'http', url: 'https://bearer.example.test/mcp', bearerTokenEnvVar: 'EXAMPLE_MCP_TOKEN', }, 'oauth-required': { transport: 'http', url: requiredUrl, auth: 'oauth', }, 'oauth-authorized': { transport: 'http', url: authorizedUrl, auth: 'oauth', }, }, }), 'utf-8', ); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); const oauth = client.engineAccessor.get(IMcpOAuthService); try { await client.engineAccessor.get(IAgentIdentity).resolved(); await oauth .getProvider('oauth-authorized', authorizedUrl) .saveTokens({ access_token: 'test-access-token', token_type: 'Bearer' }); await oauth .getProvider('sse', implicitOAuthUrl) .saveTokens({ access_token: 'stale-sse-token', token_type: 'Bearer' }); await expect(client.listGlobalMcpServerAuthStatuses({ verify: false })).resolves.toEqual([ { name: 'stdio', authStatus: 'not-applicable' }, { name: 'plain', authStatus: 'not-applicable' }, { name: 'detected', authStatus: 'not-applicable' }, { name: 'sse', authStatus: 'not-applicable' }, { name: 'sse-oauth', authStatus: 'oauth-required' }, { name: 'bearer', authStatus: 'bearer-token' }, { name: 'oauth-required', authStatus: 'oauth-required' }, { name: 'oauth-authorized', authStatus: 'oauth-authorized' }, ]); await oauth .getProvider('oauth-required', requiredUrl) .saveTokens({ access_token: 'new-test-access-token', token_type: 'Bearer' }); await oauth.invalidate('oauth-authorized', authorizedUrl, 'tokens'); await expect(client.listGlobalMcpServerAuthStatuses({ verify: false })).resolves.toEqual([ { name: 'stdio', authStatus: 'not-applicable' }, { name: 'plain', authStatus: 'not-applicable' }, { name: 'detected', authStatus: 'not-applicable' }, { name: 'sse', authStatus: 'not-applicable' }, { name: 'sse-oauth', authStatus: 'oauth-required' }, { name: 'bearer', authStatus: 'bearer-token' }, { name: 'oauth-required', authStatus: 'oauth-authorized' }, { name: 'oauth-authorized', authStatus: 'oauth-required' }, ]); } finally { await client.close(); } }, 15_000); it('restates engine MCP management Error2s as KimiError, undeclared codes as internal', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); try { const management = client.engineAccessor.get(IMcpManagementService); const listSpy = vi.spyOn(management, 'listServers'); const captureRejection = async (promise: Promise): Promise => { try { await promise; } catch (error) { return error; } return expect.unreachable('expected the call to reject'); }; try { // A declared engine code keeps its identity across the restate: the // SDK throws the same class v1 throws, and the payload serializer // accepts the code. listSpy.mockRejectedValueOnce( new Error2('mcp.oauth_failed', 'OAuth flow timed out', { details: { flowId: 'flow-1' }, }), ); const oauthError = await captureRejection(client.listGlobalMcpServers()); expect(isKimiError(oauthError)).toBe(true); expect(oauthError).toMatchObject({ code: 'mcp.oauth_failed', message: 'OAuth flow timed out', details: { flowId: 'flow-1' }, }); expect(toKimiErrorPayload(oauthError)).toMatchObject({ code: 'mcp.oauth_failed', message: 'OAuth flow timed out', }); // A code this build's registry does not declare (a newer engine than // the pinned SDK) restates as `internal` instead of minting an // undeclared KimiError code the serializer would reject. listSpy.mockRejectedValueOnce(new Error2('mcp.future_code' as never, 'from a newer engine')); const unknownError = await captureRejection(client.listGlobalMcpServers()); expect(isKimiError(unknownError)).toBe(true); expect(unknownError).toMatchObject({ code: ErrorCodes.INTERNAL, message: 'from a newer engine', }); expect(toKimiErrorPayload(unknownError)).toMatchObject({ code: ErrorCodes.INTERNAL, message: 'from a newer engine', }); } finally { listSpy.mockRestore(); } } finally { await client.close(); } }); it('close() awaits the MCP OAuth service shutdown', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); // Activate the OnDemand OAuth service, then gate its shutdown behind a // manual release: close() alone (no manual service.shutdown()) must // trigger and await that shutdown, so a host removing homeDir right after // close() cannot race in-flight token writes. client.engineAccessor.get(IMcpOAuthService); let releaseShutdown: () => void = () => undefined; const gate = new Promise((resolve) => { releaseShutdown = resolve; }); const baseShutdown = McpOAuthServiceV2.prototype.shutdown; const shutdownSpy = vi .spyOn(McpOAuthServiceV2.prototype, 'shutdown') .mockImplementation(function (this: McpOAuthServiceV2) { return baseShutdown.call(this).then(() => gate); }); try { let closed = false; const closePromise = client.close().then(() => { closed = true; }); await vi.waitFor(() => { expect(shutdownSpy).toHaveBeenCalled(); }); await new Promise((resolve) => setImmediate(resolve)); expect(closed).toBe(false); releaseShutdown(); await closePromise; expect(closed).toBe(true); } finally { releaseShutdown(); shutdownSpy.mockRestore(); } }); it('close() resolves promptly when the MCP OAuth service was never used', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); // Nothing touched IMcpOAuthService: close() force-activates the OnDemand // service only to shut it down, and that activate-then-shutdown cycle // must be a clean no-op (the proactive-refresh sweep bows out on the // shutdown flag). await expect(client.close()).resolves.toBeUndefined(); }); it('seeds the host request headers (User-Agent + X-Msh-*) into the engine', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); try { // Without this seed the managed vendors go out with the SDK's default // User-Agent and no X-Msh-* — the interactive-v2 path's identity bug. const headers = client.engineAccessor.get(IHostRequestHeaders).headers; expect(headers['User-Agent']).toBe(`kimi-code-cli/${TEST_IDENTITY.version}`); expect(headers['X-Msh-Platform']).toBe('kimi_code_cli'); expect(headers['X-Msh-Version']).toBe(TEST_IDENTITY.version); expect(headers['X-Msh-Device-Id']).toBeTruthy(); } finally { await client.close(); } }); it('surfaces a missing Git Bash probe failure during ensureConfigFile on Windows', async () => { hostEnvProbe.failWithMissingShell = true; const restorePlatform = stubProcessPlatform('win32'); try { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); try { await expect(harness.ensureConfigFile()).rejects.toBeInstanceOf(HostProcessError); await expect(harness.ensureConfigFile()).rejects.toMatchObject({ code: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, }); } finally { await harness.close(); } } finally { hostEnvProbe.failWithMissingShell = false; restorePlatform(); } }); it('does not block ensureConfigFile on the host environment probe on POSIX', async () => { hostEnvProbe.failWithMissingShell = true; const restorePlatform = stubProcessPlatform('darwin'); try { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); try { await expect(harness.ensureConfigFile()).resolves.toBeUndefined(); } finally { await harness.close(); } } finally { hostEnvProbe.failWithMissingShell = false; restorePlatform(); } }); 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('uploadFile stores bytes through the klient files facade', async () => { const { harness } = await makeHarness(); try { const bytes = new Uint8Array([137, 80, 78, 71]); const meta = await harness.uploadFile(bytes, { name: 'pixel.png', mimeType: 'image/png', expiresInSec: 60, }); expect(meta.id.startsWith('f_')).toBe(true); expect(meta.name).toBe('pixel.png'); expect(meta.media_type).toBe('image/png'); expect(meta.size).toBe(bytes.length); expect(typeof meta.created_at).toBe('string'); expect(typeof meta.expires_at).toBe('string'); // Re-export smoke only — helper behavior is pinned by agent-core-v2's // mediaRef tests. expect(isDaemonFileUrl(buildDaemonFileUrl(meta.id))).toBe(true); await harness.deleteFile(meta.id); await expect(harness.deleteFile(meta.id)).rejects.toThrow(/file not found/); } finally { await harness.close(); } }); it('emits one complete metadata event when a generated title is applied', 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 titleBaseUrl = 'https://api.example.test/coding/v1'; const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl }); // Storage names strip the `oauth/` prefix (FileTokenStorage rejects // namespaced keys); the engine resolves the same name when reading. await new FileTokenStorage(join(homeDir, 'credentials')).save( resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }), { accessToken: 'test-access-token', refreshToken: 'test-refresh-token', expiresAt: Math.floor(Date.now() / 1000) + 3600, scope: '', tokenType: 'Bearer', expiresIn: 3600, }, ); await writeFile( join(homeDir, 'config.toml'), ` default_model = "stub" [providers.stub] type = "openai" base_url = "https://model.example.test/v1" api_key = "stub" [models.stub] provider = "stub" model = "stub" max_context_size = 1000 [providers."managed:kimi-code"] type = "kimi" base_url = "${titleBaseUrl}" [providers."managed:kimi-code".oauth] storage = "file" key = "${titleOAuthRef.key}" `, 'utf-8', ); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; if (url === 'https://api.example.test/coding/v1/tools') { return new Response(JSON.stringify({ title: 'Generated title' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`Unexpected fetch: ${url}`); }); const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); try { const session = await harness.createSession({ id: 'ses_generated_title_event', workDir }); await session.importContext( 'Generate a concise title for this session', "session 'source-session'", ); await expect( harness.auth.getCachedAccessToken('managed:kimi-code', { storage: titleOAuthRef.storage, key: titleOAuthRef.key, }), ).resolves.toBe('test-access-token'); await expect(session.getContext()).resolves.toMatchObject({ history: [ expect.objectContaining({ role: 'user', origin: { kind: 'user' }, }), ], }); const events: Event[] = []; const unsubscribe = session.onEvent((event) => { if (event.type === 'session.meta.updated' && event.title === 'Generated title') { events.push(event); } }); await expect(harness.generateSessionTitle({ id: session.id })).resolves.toBe( 'Generated title', ); unsubscribe(); expect(events).toEqual([ expect.objectContaining({ type: 'session.meta.updated', sessionId: session.id, agentId: 'main', title: 'Generated title', patch: { title: 'Generated title', isCustomTitle: false }, }), ]); } finally { await harness.close(); fetchSpy.mockRestore(); } }); it('serializes a temporary title-generation close against a public resume', 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 titleBaseUrl = 'https://api.example.test/coding/v1'; const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl }); await new FileTokenStorage(join(homeDir, 'credentials')).save( resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }), { accessToken: 'test-access-token', refreshToken: 'test-refresh-token', expiresAt: Math.floor(Date.now() / 1000) + 3600, scope: '', tokenType: 'Bearer', expiresIn: 3600, }, ); await writeFile( join(homeDir, 'config.toml'), ` default_model = "stub" [providers.stub] type = "openai" base_url = "https://model.example.test/v1" api_key = "stub" [models.stub] provider = "stub" model = "stub" max_context_size = 1000 [providers."managed:kimi-code"] type = "kimi" base_url = "${titleBaseUrl}" [providers."managed:kimi-code".oauth] storage = "file" key = "${titleOAuthRef.key}" `, 'utf-8', ); let markFetchStarted!: () => void; let resolveFetch!: (response: Response) => void; const fetchStarted = new Promise((resolve) => { markFetchStarted = resolve; }); const fetchResponse = new Promise((resolve) => { resolveFetch = resolve; }); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; if (url === 'https://api.example.test/coding/v1/tools') { markFetchStarted(); return fetchResponse; } throw new Error(`Unexpected fetch: ${url}`); }); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); try { await client.createSession({ id: 'ses_title_race', workDir }); await client.importContext({ sessionId: 'ses_title_race', content: 'Generate a concise title for this session', source: "session 'source-session'", }); await client.closeSession({ sessionId: 'ses_title_race' }); // The cold session is temporarily resumed for generation; block its // cleanup close inside the will-close hooks so the public resume below // lands while the close is still in flight. const titlePromise = client.generateSessionTitle({ id: 'ses_title_race' }); await fetchStarted; const sessionManager = client.engineAccessor.get(ISessionManager); const tempHandle = sessionManager.get('ses_title_race'); expect(tempHandle).toBeDefined(); let markCloseStarted!: () => void; let openCloseGate!: () => void; const closeStarted = new Promise((resolve) => { markCloseStarted = resolve; }); const closeGate = new Promise((resolve) => { openCloseGate = resolve; }); sessionManager.onWillCloseSession!((event) => { if (event.sessionId !== 'ses_title_race') return; markCloseStarted(); event.waitUntil(closeGate); }); resolveFetch( new Response(JSON.stringify({ title: 'Generated title' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }), ); await closeStarted; // The resume must queue behind the in-flight close instead of merging // into the handle that is being torn down. const order: string[] = []; const resumePromise = client.resumeSession({ id: 'ses_title_race' }).then((summary) => { order.push('resumed'); return summary; }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(order).toEqual([]); openCloseGate(); await expect(titlePromise).resolves.toBe('Generated title'); const summary = await resumePromise; expect(summary.id).toBe('ses_title_race'); expect(order).toEqual(['resumed']); // The resumed session is a fresh, fully usable scope — not the handle // the temporary path just tore down. await client.renameSession({ id: 'ses_title_race', title: 'Resumed title' }); await expect .poll( async () => (await client.listSessions({ workDir })).find((item) => item.id === 'ses_title_race') ?.title, { interval: 50, timeout: 4000 }, ) .toBe('Resumed title'); } finally { await client.close(); fetchSpy.mockRestore(); } }); it('re-resumes a fresh session facade while the public close is in flight', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const session = await harness.createSession({ id: 'ses_resume_race', workDir }); // close() flips `isClosed` synchronously; the engine close settles // asynchronously. The public resume must not hand back the closing // facade — it queues behind the close and materializes a fresh one. const closing = session.close(); const resumed = await harness.resumeSession({ id: 'ses_resume_race' }); await closing; expect(resumed).not.toBe(session); expect(session.isClosed).toBe(true); expect(resumed.isClosed).toBe(false); expect(resumed.getResumeState()).toBeTruthy(); // The stale facade's late onClose must not evict the live session. expect(harness.getSession('ses_resume_race')).toBe(resumed); } finally { await harness.close(); } }); it('rejects one of two concurrent creates with the same explicit session id', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const [first, second] = await Promise.allSettled([ harness.createSession({ id: 'ses_same_id', workDir }), harness.createSession({ id: 'ses_same_id', workDir }), ]); const outcomes = [first, second].map((result) => result.status); expect(outcomes.toSorted()).toEqual(['fulfilled', 'rejected']); const rejection = [first, second].find((result) => result.status === 'rejected'); expect((rejection as PromiseRejectedResult).reason).toMatchObject({ code: 'session.already_exists', }); await expect(harness.resumeSession({ id: 'ses_same_id' })).resolves.toMatchObject({ id: 'ses_same_id', }); } finally { await harness.close(); } }); it('coalesces concurrent public resumes onto one session facade', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const session = await harness.createSession({ id: 'ses_coalesce', workDir }); await session.close(); const [first, second] = await Promise.all([ harness.resumeSession({ id: 'ses_coalesce' }), harness.resumeSession({ id: 'ses_coalesce' }), ]); // One engine handle, one facade: a later close on either reference // must not strand a second live facade over the same handle. expect(first).toBe(second); expect(harness.getSession('ses_coalesce')).toBe(first); } finally { await harness.close(); } }); it('does not coalesce resumes with different options onto one facade', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const session = await harness.createSession({ id: 'ses_no_coalesce', workDir }); await session.close(); const [plain, withReplay] = await Promise.all([ harness.resumeSession({ id: 'ses_no_coalesce' }), harness.resumeSession({ id: 'ses_no_coalesce', replayTurnLimit: 3 }), ]); // Different options must not be silently dropped onto the first // caller's facade — each gets its own resume. expect(plain).not.toBe(withReplay); } finally { await harness.close(); } }); it('reports the title state in the resumed summary', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const session = await harness.createSession({ id: 'ses_title_kind', workDir }); await harness.renameSession({ id: session.id, title: '我的标题' }); // The resumed summary is read off the live metadata document, so it // carries the canonical title state; the list path (index projection) // intentionally does not. await session.close(); const resumed = await harness.resumeSession({ id: session.id }); expect(resumed.summary?.titleKind).toBe('custom'); } 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('serves suggestFiles through the workspace handler fs service', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); await mkdir(join(workDir, 'src'), { recursive: true }); await writeFile(join(workDir, 'src', 'app.ts'), 'app'); await writeFile(join(workDir, 'src', 'index.ts'), 'index'); await writeFile(join(workDir, 'README.md'), 'readme'); try { const matched = await harness.suggestFiles(workDir, { query: 'app', limit: 20 }); expect(matched?.items).toContainEqual( expect.objectContaining({ kind: 'file', path: 'src/app.ts', name: 'app.ts' }), ); const appItem = matched?.items.find((item) => item.name === 'app.ts'); expect(appItem?.matchPositions.length).toBeGreaterThan(0); const topLevel = await harness.suggestFiles(workDir, { query: '', limit: 20 }); expect(topLevel?.items).toContainEqual(expect.objectContaining({ kind: 'directory', name: 'src' })); expect(topLevel?.items).toContainEqual(expect.objectContaining({ kind: 'file', name: 'README.md' })); } finally { await harness.close(); } }); it('rejects an out-of-range suggestFiles limit before touching the engine', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { for (const limit of [0, -1, 201, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { await expect(harness.suggestFiles(workDir, { query: 'a', limit })).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID, }); } } 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 = createKimiHarness({ 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('persists removeProvider as one atomic cascade (providers, models, defaults)', async () => { const { harness } = await makeHarness(); try { await harness.setConfig({ providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' }, b: { type: 'openai', baseUrl: 'https://b.example.test/v1', apiKey: 'sk-b' }, }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, defaultModel: 'b/m1', defaultProvider: 'b', }); const next = await harness.removeProvider('b'); expect(next.providers['b']).toBeUndefined(); expect(next.providers['a']).toBeDefined(); expect(next.models?.['b/m1']).toBeUndefined(); expect(next.models?.['a/m1']).toBeDefined(); expect(next.defaultModel).toBeUndefined(); expect(next.defaultProvider).toBeUndefined(); // A fresh read from disk sees the same state — the cascade landed as a // single atomic write, never a halfway-removed intermediate. const reread = await harness.getConfig({ reload: true }); expect(reread.providers['b']).toBeUndefined(); expect(reread.models?.['b/m1']).toBeUndefined(); expect(reread.defaultModel).toBeUndefined(); expect(reread.defaultProvider).toBeUndefined(); } finally { await harness.close(); } }); it('leaves the secondary_model pool untouched on removeProvider', async () => { const { harness } = await makeHarness(); try { await harness.setConfig({ providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' }, b: { type: 'openai', baseUrl: 'https://b.example.test/v1', apiKey: 'sk-b' }, }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, secondaryModel: { defaultModel: 'a/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }, }); // Pool entries naming a removed model alias are kept as written; an // unresolvable entry fails pool validation on the next session create. const kept = await harness.removeProvider('b'); expect(kept.secondaryModel).toEqual({ defaultModel: 'a/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }); // Even a dangling default leaves the whole section in place on disk. // (`setConfig` merges per domain, so the pool table is still the one // written above; the default now points at the provider being removed.) await harness.setConfig({ secondaryModel: { defaultModel: 'a/m1' }, }); const cleared = await harness.removeProvider('a'); expect(cleared.secondaryModel).toEqual({ defaultModel: 'a/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }); const reread = await harness.getConfig({ reload: true }); expect(reread.secondaryModel).toEqual({ defaultModel: 'a/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }); } finally { await harness.close(); } }); it('replaces config sections atomically and clears undefined sections', async () => { const { harness } = await makeHarness(); try { expect(harness.supportsAtomicSectionReplace()).toBe(true); await harness.setConfig({ providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1', apiKey: 'sk-a' } }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 } }, defaultModel: 'a/m1', }); await harness.replaceConfigSections({ defaultModel: undefined }); const next = await harness.getConfig({ reload: true }); expect(next.defaultModel).toBeUndefined(); // Sections absent from the write stay untouched. expect(next.providers['a']).toBeDefined(); expect(next.models?.['a/m1']).toBeDefined(); } finally { await harness.close(); } }); it('round-trips the secondaryModel pool field to the [secondary_model] config section', async () => { const { harness, homeDir } = await makeHarness(); try { await harness.setConfig({ secondaryModel: { defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' }, }, }); const toml = await readFile(join(homeDir, 'config.toml'), 'utf-8'); expect(toml).toContain('[secondary_model]'); expect(toml).toContain('default_model'); expect(toml).toContain('[secondary_model.models]'); expect(toml).not.toContain('[subagent.models]'); const reread = await harness.getConfig({ reload: true }); expect(reread.secondaryModel).toEqual({ defaultModel: 'provider/fast', models: { 'provider/fast': 'fast and cheap' }, }); } finally { await harness.close(); } }); it('deleteSession removes a session and rejects a missing id with session_not_found', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const session = await harness.createSession({ workDir }); await harness.deleteSession(session.id); await expect(harness.resumeSession({ id: session.id })).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND, }); expect(await sessionDirExists(homeDir, session.id)).toBe(false); await expect(harness.deleteSession('session_missing')).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND, }); } finally { await harness.close(); } }); it.each([ { enabled: false, panel: true }, { enabled: true, panel: false }, { enabled: true, panel: true }, ])( 'gates NotifyUser for all profiles and preserves fork prompts: %j', async ({ enabled, panel }) => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); vi.stubEnv('KIMI_CODE_EXPERIMENTAL_NOTIFY_USER', ''); const homeDir = await mkdtemp(join(tmpdir(), 'kimi-notify-home-')); const workDir = await mkdtemp(join(tmpdir(), 'kimi-notify-work-')); tempDirs.push(homeDir, workDir); const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY, uiCapabilities: panel ? ['update_panel'] : [], }); try { await client.setConfig({ providers: { stub: { type: 'openai', baseUrl: 'https://model.example.test/v1', apiKey: 'YOUR_API_KEY', }, }, models: { stub: { provider: 'stub', model: 'stub', maxContextSize: 32000 } }, defaultModel: 'stub', experimental: { notify_user: enabled }, }); await client.createSession({ id: 'ses_notify', workDir }); const session = getLiveSessionById(client.engineAccessor, 'ses_notify')!; const lifecycle = session.accessor.get(IAgentLifecycleService); for (const profile of ['agent', 'coder', 'explore', 'plan']) { const id = profile === 'agent' ? 'main' : `worker-${profile}`; if (lifecycle.get(id) === undefined) await lifecycle.create({ agentId: id, binding: { profile, model: 'stub' } }); const agent = lifecycle.handleOf(id)!; await agent.accessor.get(IAgentToolActivationService).activate(); const offered = agent.accessor .get(IAgentToolRegistryService) .list() .some((tool) => tool.name === 'NotifyUser'); expect(offered).toBe(enabled && panel); if (profile === 'agent') { const delegation = agent.accessor .get(IAgentToolRegistryService) .list() .find((tool) => tool.name === 'Agent'); expect(delegation?.description.includes('NotifyUser')).toBe(enabled && panel); } const prompt = agent.accessor.get(IAgentProfileService).getSystemPrompt(); expect(prompt.includes('When `NotifyUser` is available')).toBe(enabled && panel); if (offered) { expect( agent.accessor .get(INotifyUserTool) .resolveExecution({ message: 'Checking this subtask.' }), ).toMatchObject({ accesses: [], approvalRule: 'NotifyUser' }); } } const main = lifecycle.handleOf('main')!; const originalPrompt = main.accessor.get(IAgentProfileService).getSystemPrompt(); const fork = await lifecycle.fork(lifecycle.get('main')!, { agentId: 'fork-worker' }); expect( lifecycle.handleOf(fork.agentId)!.accessor.get(IAgentProfileService).getSystemPrompt(), ).toBe(originalPrompt); const originalTools = structuredClone(main.accessor.get(IAgentToolRegistryService).list()); for (const [index, nextEnabled] of [!enabled, enabled, !enabled].entries()) { await client.setConfig({ experimental: { notify_user: nextEnabled } }); let current = getLiveSessionById(client.engineAccessor, 'ses_notify')!; let currentMain = current.accessor.get(IAgentLifecycleService).handleOf('main')!; expect(currentMain.accessor.get(IAgentProfileService).getSystemPrompt()).toBe( originalPrompt, ); expect(currentMain.accessor.get(IAgentToolRegistryService).list()).toEqual(originalTools); if (enabled && panel) { const execution = currentMain.accessor .get(INotifyUserTool) .resolveExecution({ message: 'Still working.' }); if (!('execute' in execution)) throw new Error('Expected executable notification'); expect( await execution.execute({ signal: new AbortController().signal } as never), ).toEqual({ isError: false, output: nextEnabled ? 'Update shown to the user.' : 'Notifications are disabled; the update was not displayed.', }); } await current.accessor.get(IAgentLifecycleService).create({ agentId: `late-worker-${index}`, binding: { profile: 'coder', model: 'stub' }, }); const child = current.accessor .get(IAgentLifecycleService) .handleOf(`late-worker-${index}`)!; expect( child.accessor .get(IAgentToolRegistryService) .list() .some((tool) => tool.name === 'NotifyUser'), ).toBe(enabled && panel); expect( child.accessor .get(IAgentProfileService) .getSystemPrompt() .includes('When `NotifyUser` is available'), ).toBe(enabled && panel); await client.reloadSession({ sessionId: 'ses_notify' }); current = getLiveSessionById(client.engineAccessor, 'ses_notify')!; currentMain = current.accessor.get(IAgentLifecycleService).handleOf('main')!; expect(currentMain.accessor.get(IAgentToolRegistryService).list()).toEqual(originalTools); expect(currentMain.accessor.get(IAgentProfileService).getSystemPrompt()).toBe( originalPrompt, ); } await client.createSession({ id: 'ses_notify_fresh', workDir }); await client.setConfig({ experimental: { notify_user: enabled } }); await client.getStatus({ sessionId: 'ses_notify_fresh' }); const fresh = getLiveSessionById(client.engineAccessor, 'ses_notify_fresh')! .accessor.get(IAgentLifecycleService) .handleOf('main')!; expect( fresh.accessor .get(IAgentToolRegistryService) .list() .some((tool) => tool.name === 'NotifyUser'), ).toBe(!enabled && panel); expect( fresh.accessor .get(IAgentProfileService) .getSystemPrompt() .includes('When `NotifyUser` is available'), ).toBe(!enabled && panel); if (enabled || !panel) { expect(fresh.accessor.get(IAgentProfileService).getSystemPrompt()).not.toContain( 'NotifyUser', ); expect( JSON.stringify(fresh.accessor.get(IAgentToolRegistryService).list()), ).not.toContain('NotifyUser'); } await client.setConfig({ experimental: { notify_user: !enabled } }); const legacy = getLiveSessionById(client.engineAccessor, 'ses_notify')!; await legacy.accessor .get(IAtomicDocumentStore) .delete(legacy.accessor.get(ISessionContext).scope('notify'), 'state.json'); await client.reloadSession({ sessionId: 'ses_notify' }); const migrated = getLiveSessionById(client.engineAccessor, 'ses_notify')! .accessor.get(IAgentLifecycleService) .handleOf('main')!; expect(migrated.accessor.get(IAgentProfileService).getSystemPrompt()).toBe(originalPrompt); expect(migrated.accessor.get(IAgentToolRegistryService).list()).toEqual(originalTools); } finally { await client.close(); vi.unstubAllEnvs(); } }, 30_000, ); it('serves getTodos from the live session todo state', 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 client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); try { await client.createSession({ id: 'ses_todos', workDir }); expect(await client.getTodos({ sessionId: 'ses_todos' })).toEqual([]); const handle = getLiveSessionById(client.engineAccessor, 'ses_todos'); expect(handle).toBeDefined(); const manager = handle!.accessor.get(IAgentLifecycleService); await manager.create({ agentId: 'main' }); const todo = manager.handleOf('main')!.accessor.get(IAgentTodoService); await todo.replace([ { title: 'write tests', status: 'in_progress' }, { title: 'ship it', status: 'pending' }, ]); expect(await client.getTodos({ sessionId: 'ses_todos' })).toEqual([ { title: 'write tests', status: 'in_progress' }, { title: 'ship it', status: 'pending' }, ]); const served = await client.getTodos({ sessionId: 'ses_todos' }); const stored = todo.get(); expect(served).not.toBe(stored); expect(served[0]).not.toBe(stored[0]); await expect(client.getTodos({ sessionId: 'ses_missing' })).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND, }); } finally { await client.close(); } }); it('serves setTowerMode and getStatus towerMode through the agent scope tower service', async () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '1'); 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 client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); try { await client.createSession({ id: 'ses_tower', workDir }); expect((await client.getStatus({ sessionId: 'ses_tower' })).towerMode).toBe(false); const mainTower = () => { const handle = getLiveSessionById(client.engineAccessor, 'ses_tower'); expect(handle).toBeDefined(); const agent = handle!.accessor.get(IAgentLifecycleService).handleOf('main'); expect(agent).toBeDefined(); return agent!.accessor.get(IAgentTowerService); }; await client.setTowerMode({ sessionId: 'ses_tower', enabled: true }); // A refused enter() rejects with a typed reason, so a resolved call // means the engine activated tower mode; the wire mirrors it. expect((await client.getStatus({ sessionId: 'ses_tower' })).towerMode).toBe( mainTower().isActive, ); await client.setTowerMode({ sessionId: 'ses_tower', enabled: false }); expect((await client.getStatus({ sessionId: 'ses_tower' })).towerMode).toBe(false); await expect(client.setTowerMode({ sessionId: 'ses_missing', enabled: true })) .rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND, }); } finally { vi.unstubAllEnvs(); await client.close(); } }); it('rejects setTowerMode when the tower feature is unavailable', async () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '0'); vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); 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 client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); try { await client.createSession({ id: 'ses_tower_off', workDir }); await expect(client.setTowerMode({ sessionId: 'ses_tower_off', enabled: true })) .rejects.toMatchObject({ code: 'session.tower_mode_invalid', message: expect.stringContaining('the tower experiment is disabled'), }); expect((await client.getStatus({ sessionId: 'ses_tower_off' })).towerMode).toBe(false); await client.setTowerMode({ sessionId: 'ses_tower_off', enabled: false }); expect((await client.getStatus({ sessionId: 'ses_tower_off' })).towerMode).toBe(false); } finally { vi.unstubAllEnvs(); await client.close(); } }); it('exposes Session.setTowerMode and getStatus().towerMode on the v2 harness', async () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TOWER', '1'); const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { const session = await harness.createSession({ workDir }); expect((await session.getStatus()).towerMode).toBe(false); await expect(session.setTowerMode(true)).resolves.toBeUndefined(); expect(typeof (await session.getStatus()).towerMode).toBe('boolean'); await expect(session.setTowerMode(false)).resolves.toBeUndefined(); expect((await session.getStatus()).towerMode).toBe(false); await expect(session.setTowerMode('yes' as unknown as boolean)).rejects.toMatchObject({ code: ErrorCodes.REQUEST_INVALID, }); } finally { vi.unstubAllEnvs(); await harness.close(); } }); }); describe('SDKRpcClientV2 workspace trust', () => { it('reports an untrusted workspace with the project MCP servers it gates', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); await writeFile( join(workDir, '.mcp.json'), JSON.stringify({ mcpServers: { 'root-server': { command: 'root-cmd', args: ['--safe'], cwd: '/tmp/root', env: { SECRET: 'hidden' }, }, 'http-server': { transport: 'http', url: 'https://example.test/mcp', headers: { Authorization: 'Bearer hidden' }, bearerTokenEnvVar: 'TOKEN', }, }, }), 'utf-8', ); await mkdir(join(workDir, '.kimi-code'), { recursive: true }); await writeFile( join(workDir, '.kimi-code', 'mcp.json'), JSON.stringify({ mcpServers: { 'nested-server': { command: 'nested-cmd' } } }), 'utf-8', ); try { const info = await harness.getWorkspaceTrustInfo(workDir); expect(info.trusted).toBe(false); expect(info.gatedMcpServers).toEqual([ { name: 'http-server', transport: 'http', url: 'https://example.test/mcp' }, { name: 'nested-server', transport: 'stdio', command: 'nested-cmd' }, { name: 'root-server', transport: 'stdio', command: 'root-cmd', args: ['--safe'], cwd: '/tmp/root' }, ]); const serialized = JSON.stringify(info); expect(serialized).not.toContain('hidden'); expect(serialized).not.toContain('SECRET'); expect(serialized).not.toContain('TOKEN'); } finally { await harness.close(); } }); it('reports project servers that override same-named user entries', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); await writeFile( join(homeDir, 'mcp.json'), JSON.stringify({ mcpServers: { github: { command: 'user-github', enabled: false }, }, }), 'utf-8', ); await writeFile( join(workDir, '.mcp.json'), JSON.stringify({ mcpServers: { github: { command: 'project-github', enabled: false }, toString: { transport: 'http', url: 'https://example.test/mcp', enabled: false }, }, }), 'utf-8', ); try { const info = await harness.getWorkspaceTrustInfo(workDir); expect(info.trusted).toBe(false); expect(info.gatedMcpServers).toEqual([ { name: 'github', transport: 'stdio', command: 'project-github', args: undefined, cwd: workDir, }, { name: 'toString', transport: 'http', url: 'https://example.test/mcp' }, ]); } finally { await harness.close(); } }); it('degrades the gated-server list to empty on an invalid project mcp.json', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); await writeFile(join(workDir, '.mcp.json'), '{not json', 'utf-8'); try { const info = await harness.getWorkspaceTrustInfo(workDir); expect(info).toEqual({ trusted: false, gatedMcpServers: [] }); } finally { await harness.close(); } }); it('trustWorkspace flips the state and persists the marker in the kimi home', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); try { await harness.trustWorkspace(workDir); expect(await harness.getWorkspaceTrustInfo(workDir)).toEqual({ trusted: true, gatedMcpServers: [], }); // The trust marker lives in the kimi home, never in the checkout. const markers = await readdir(join(homeDir, 'workspace-trust')); expect(markers.length).toBe(1); expect(await readdir(workDir)).not.toContain('workspace-trust'); } 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 = createKimiHarness({ 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 = createKimiHarness({ 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(); } }); it('emits session_started once per open, with the harness schema and enabled experimental flags', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-flags-')); tempDirs.push(homeDir); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-flags-work-')); tempDirs.push(workDir); await writeFile(join(homeDir, 'config.toml'), '[experimental]\nsubagent_fork = true\n', 'utf-8'); const records: TelemetryRecord[] = []; const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY, telemetry: recordingTelemetry(records), }); try { const session = await harness.createSession({ workDir }); // The harness row is the sole producer: the forwarding appender drops // the engine's own session_started, or every open would double-count. const started = records.filter((record) => record.event === 'session_started'); expect(started).toHaveLength(1); expect(started[0]).toMatchObject({ sessionId: session.id, properties: { client_id: '', client_name: 'kimi-code-cli', client_version: '0.0.0-test', ui_mode: 'shell', resumed: false, }, }); for (const record of started) { const flags = String(record.properties?.['experimental_flags'] ?? '').split(','); expect(flags).toContain('subagent_fork'); expect(flags).toContain('wait_for'); } await session.close(); await harness.resumeSession({ id: session.id }); const afterResume = records.filter((record) => record.event === 'session_started'); expect(afterResume).toHaveLength(2); expect(afterResume[1]).toMatchObject({ sessionId: session.id, properties: { resumed: true }, }); const distinct = new Set(afterResume.map((record) => record.properties?.['experimental_flags'])); expect(distinct.size).toBe(1); } finally { await harness.close(); } }); it('keeps forwarding the engine session_started to a direct SDKRpcClientV2 consumer', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-direct-')); tempDirs.push(homeDir); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-tel-direct-work-')); tempDirs.push(workDir); const records: TelemetryRecord[] = []; const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY, telemetry: recordingTelemetry(records), }); try { // No harness wraps this client, so nothing else emits session_started — // the engine's own row must survive forwarding. const summary = await client.createSession({ workDir }); const started = records.filter((record) => record.event === 'session_started'); expect(started).toHaveLength(1); expect(started[0]).toMatchObject({ properties: { resumed: false } }); await client.closeSession({ sessionId: summary.id }); await client.resumeSession({ id: summary.id }); const afterResume = records.filter((record) => record.event === 'session_started'); expect(afterResume).toHaveLength(2); expect(afterResume[1]).toMatchObject({ properties: { resumed: true } }); } finally { await client.close(); } }); }); describe('removeProviderFromConfig', () => { it('drops the provider, its models and dangling default pointers without mutating the input', () => { const config = { providers: { a: { type: 'openai', baseUrl: 'https://a.example.test/v1' }, b: { type: 'openai', baseUrl: 'https://b.example.test/v1' }, }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, 'my-b': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, defaultModel: 'my-b', defaultProvider: 'b', } as unknown as KimiConfig; const next = removeProviderFromConfig(config, 'b'); expect(Object.keys(next.providers)).toEqual(['a']); expect(Object.keys(next.models ?? {})).toEqual(['a/m1']); expect(next.defaultModel).toBeUndefined(); expect(next.defaultProvider).toBeUndefined(); // The input config is left untouched (the staging host threads the copy). expect(config.providers['b']).toBeDefined(); expect(config.models?.['b/m1']).toBeDefined(); expect(config.defaultModel).toBe('my-b'); }); it('keeps the default pointers when they do not dangle', () => { const config = { providers: { a: { type: 'openai' }, b: { type: 'openai' }, }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, defaultModel: 'a/m1', defaultProvider: 'a', } as unknown as KimiConfig; const next = removeProviderFromConfig(config, 'b'); expect(Object.keys(next.providers)).toEqual(['a']); expect(Object.keys(next.models ?? {})).toEqual(['a/m1']); expect(next.defaultModel).toBe('a/m1'); expect(next.defaultProvider).toBe('a'); }); it('leaves secondary_model pool entries alone when their model alias was removed', () => { const config = { providers: { a: { type: 'openai' }, b: { type: 'openai' } }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, secondaryModel: { defaultModel: 'a/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }, } as unknown as KimiConfig; const next = removeProviderFromConfig(config, 'b'); expect(next.secondaryModel).toEqual({ defaultModel: 'a/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }); }); it('keeps the secondary_model section even when its default model dangles', () => { const config = { providers: { a: { type: 'openai' }, b: { type: 'openai' } }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, secondaryModel: { defaultModel: 'b/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }, } as unknown as KimiConfig; expect(removeProviderFromConfig(config, 'b').secondaryModel).toEqual({ defaultModel: 'b/m1', models: { 'a/m1': 'fast', 'b/m1': 'smart' }, }); // The legacy recipe's `model` key is left alone the same way. const legacy = { ...config, secondaryModel: { model: 'b/m1', default_effort: 'low' }, } as unknown as KimiConfig; expect(removeProviderFromConfig(legacy, 'b').secondaryModel).toEqual({ model: 'b/m1', default_effort: 'low', }); }); it('leaves the secondary_model section untouched when nothing dangles', () => { const config = { providers: { a: { type: 'openai' }, b: { type: 'openai' } }, models: { 'a/m1': { provider: 'a', model: 'm1', maxContextSize: 100 }, 'b/m1': { provider: 'b', model: 'm1', maxContextSize: 100 }, }, secondaryModel: { defaultModel: 'a/m1' }, } as unknown as KimiConfig; const next = removeProviderFromConfig(config, 'b'); expect(next.secondaryModel).toEqual({ defaultModel: 'a/m1' }); }); }); 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', ); }