kimi-code / packages /node-sdk /test /sdk-rpc-client-v2.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
69.5 kB
/**
* 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 `<home>/sessions/<bucket>/<id>`. */
async function sessionDirExists(homeDir: string, sessionId: string): Promise<boolean> {
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<unknown>): Promise<unknown> => {
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<void>((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<void>((resolve) => {
markFetchStarted = resolve;
});
const fetchResponse = new Promise<Response>((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<void>((resolve) => {
markCloseStarted = resolve;
});
const closeGate = new Promise<void>((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<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',
);
}