mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 06:05:36 +00:00
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
|
|
import { KIMI_CODE_PLATFORM } from '@moonshot-ai/kimi-code-oauth';
|
|
import type * as KosongModule from '@moonshot-ai/kosong';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createKimiHarness, type Event, type KimiHarness } from '#/index';
|
|
|
|
import { TEST_IDENTITY } from './test-identity';
|
|
|
|
const fakeProviderState = vi.hoisted(() => ({
|
|
calls: [] as Array<{
|
|
readonly systemPrompt: string;
|
|
readonly history: unknown;
|
|
}>,
|
|
providerConfigs: [] as unknown[],
|
|
responseText: 'hello from fake provider',
|
|
}));
|
|
|
|
vi.mock('@moonshot-ai/kosong', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof KosongModule>();
|
|
return {
|
|
...actual,
|
|
createProvider: (config: unknown) => {
|
|
fakeProviderState.providerConfigs.push(config);
|
|
return {
|
|
name: 'fake',
|
|
modelName: 'fake-model',
|
|
thinkingEffort: null,
|
|
async generate(systemPrompt: string, _tools: unknown, history: unknown) {
|
|
fakeProviderState.calls.push({ systemPrompt, history });
|
|
return {
|
|
id: 'fake-response',
|
|
usage: {
|
|
inputOther: 0,
|
|
output: 1,
|
|
inputCacheRead: 0,
|
|
inputCacheCreation: 0,
|
|
},
|
|
finishReason: 'completed',
|
|
rawFinishReason: 'stop',
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: 'text', text: fakeProviderState.responseText };
|
|
},
|
|
};
|
|
},
|
|
withThinking() {
|
|
return this;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
fakeProviderState.calls.length = 0;
|
|
fakeProviderState.providerConfigs.length = 0;
|
|
fakeProviderState.responseText = 'hello from fake provider';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await removeTempDir(dir);
|
|
}
|
|
});
|
|
|
|
async function makeTempDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-prompt-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function removeTempDir(dir: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
try {
|
|
await rm(dir, { recursive: true, force: true });
|
|
return;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== 'ENOTEMPTY' && code !== 'EBUSY' && code !== 'EPERM') {
|
|
throw error;
|
|
}
|
|
await delay(10);
|
|
}
|
|
}
|
|
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
describe('Session.prompt events', () => {
|
|
it('persists sanitized prompt metadata without marking the title custom', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await configureFakeProvider(harness);
|
|
const session = await harness.createSession({ id: 'ses_prompt_meta', workDir });
|
|
const events: Event[] = [];
|
|
const unsubscribe = session.onEvent((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
let done = waitForEvent(session, (event) => event.type === 'turn.ended');
|
|
await session.prompt('use api_key=secret-value for the request');
|
|
await done;
|
|
|
|
const statePath = join(session.summary!.sessionDir, 'state.json');
|
|
const firstState = JSON.parse(await readFile(statePath, 'utf-8')) as Record<string, unknown>;
|
|
expect(firstState['title']).toBe('use api_key=[redacted] for the request');
|
|
expect(firstState['isCustomTitle']).toBe(false);
|
|
expect(firstState['lastPrompt']).toBe('use api_key=[redacted] for the request');
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'session.meta.updated',
|
|
title: 'use api_key=[redacted] for the request',
|
|
patch: expect.objectContaining({
|
|
isCustomTitle: false,
|
|
lastPrompt: 'use api_key=[redacted] for the request',
|
|
}),
|
|
}),
|
|
);
|
|
|
|
events.length = 0;
|
|
done = waitForEvent(session, (event) => event.type === 'turn.ended');
|
|
await session.prompt('second prompt');
|
|
await done;
|
|
|
|
const secondState = JSON.parse(await readFile(statePath, 'utf-8')) as Record<string, unknown>;
|
|
expect(secondState['title']).toBe('use api_key=[redacted] for the request');
|
|
expect(secondState['isCustomTitle']).toBe(false);
|
|
expect(secondState['lastPrompt']).toBe('second prompt');
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'session.meta.updated',
|
|
patch: expect.objectContaining({
|
|
lastPrompt: 'second prompt',
|
|
}),
|
|
}),
|
|
);
|
|
|
|
events.length = 0;
|
|
done = waitForEvent(session, (event) => event.type === 'turn.ended');
|
|
await session.prompt([{ type: 'image_url', imageUrl: { url: 'https://example.com/a.png' } }]);
|
|
await done;
|
|
unsubscribe();
|
|
|
|
const mediaState = JSON.parse(await readFile(statePath, 'utf-8')) as Record<string, unknown>;
|
|
expect(mediaState['title']).toBe('use api_key=[redacted] for the request');
|
|
expect(mediaState['lastPrompt']).toBe('[image]');
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'session.meta.updated',
|
|
patch: expect.objectContaining({
|
|
lastPrompt: '[image]',
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('emits mapped turn events through Session.onEvent', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await configureFakeProvider(harness);
|
|
const session = await harness.createSession({ id: 'ses_prompt_events', workDir });
|
|
const events: Event[] = [];
|
|
const done = waitForEvent(session, (event) => event.type === 'turn.ended');
|
|
const unsubscribe = session.onEvent((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
await session.prompt('hello');
|
|
await done;
|
|
unsubscribe();
|
|
|
|
expect(events.some((event) => event.type === 'turn.started')).toBe(true);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'assistant.delta',
|
|
sessionId: session.id,
|
|
turnId: 0,
|
|
delta: 'hello from fake provider',
|
|
}),
|
|
);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.ended',
|
|
sessionId: session.id,
|
|
turnId: 0,
|
|
reason: 'completed',
|
|
}),
|
|
);
|
|
expect(fakeProviderState.calls[0]?.systemPrompt).toContain('You are Kimi Code CLI');
|
|
expect(fakeProviderState.calls[0]?.systemPrompt).toContain('Available skills');
|
|
expect(fakeProviderState.providerConfigs[0]).toMatchObject({
|
|
type: 'kimi',
|
|
defaultHeaders: expect.objectContaining({
|
|
'X-Msh-Platform': KIMI_CODE_PLATFORM,
|
|
'User-Agent': 'kimi-code-cli/0.0.0-test',
|
|
}),
|
|
});
|
|
expect(existsSync(join(homeDir, 'device_id'))).toBe(true);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('supports onEvent unsubscribe without touching runtime wire directly', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await configureFakeProvider(harness);
|
|
const session = await harness.createSession({ id: 'ses_prompt_unsubscribe', workDir });
|
|
const unsubscribedEvents: Event[] = [];
|
|
const unsubscribe = session.onEvent((event) => {
|
|
unsubscribedEvents.push(event);
|
|
});
|
|
unsubscribe();
|
|
const done = waitForEvent(session, (event) => event.type === 'turn.ended');
|
|
|
|
await session.prompt([{ type: 'text', text: 'hello' }]);
|
|
await done;
|
|
|
|
expect(unsubscribedEvents).toEqual([]);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('runs init through generateAgentsMd RPC as a subagent system trigger without prompt metadata updates', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await configureFakeProvider(harness);
|
|
const session = await harness.createSession({ id: 'ses_init_rpc', workDir });
|
|
const events: Event[] = [];
|
|
const unsubscribe = session.onEvent((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
await session.init();
|
|
unsubscribe();
|
|
|
|
const spawned = events.find((event) => event.type === 'subagent.spawned');
|
|
expect(spawned).toMatchObject({
|
|
type: 'subagent.spawned',
|
|
sessionId: session.id,
|
|
agentId: 'main',
|
|
subagentName: 'coder',
|
|
parentToolCallId: 'generate-agents-md',
|
|
});
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.started',
|
|
sessionId: session.id,
|
|
agentId: spawned?.type === 'subagent.spawned' ? spawned.subagentId : undefined,
|
|
origin: { kind: 'system_trigger', name: 'subagent' },
|
|
}),
|
|
);
|
|
expect(events).not.toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'session.meta.updated',
|
|
}),
|
|
);
|
|
expect(fakeProviderState.calls[0]?.history).toMatchObject([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
expect.objectContaining({
|
|
text: expect.stringContaining('Task requirements:'),
|
|
}),
|
|
],
|
|
},
|
|
]);
|
|
|
|
const statePath = join(session.summary!.sessionDir, 'state.json');
|
|
const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record<string, unknown>;
|
|
expect(state['lastPrompt']).toBeUndefined();
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('starts btw through RPC as a forked subagent without prompt metadata updates', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await configureFakeProvider(harness);
|
|
const session = await harness.createSession({ id: 'ses_btw_rpc', workDir });
|
|
const events: Event[] = [];
|
|
const unsubscribe = session.onEvent((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
let done = waitForEvent(session, (event) => event.type === 'turn.ended');
|
|
await session.prompt('main task context');
|
|
await done;
|
|
|
|
fakeProviderState.responseText = 'The main agent is working from the existing context.';
|
|
events.length = 0;
|
|
done = waitForEvent(
|
|
session,
|
|
(event) => event.type === 'turn.ended' && event.agentId !== 'main',
|
|
);
|
|
|
|
const agentId = await session.startBtw();
|
|
await harness.withInteractiveAgent(agentId, () =>
|
|
session.prompt('What are you working on right now?'),
|
|
);
|
|
await done;
|
|
unsubscribe();
|
|
expect(harness.interactiveAgentId).toBe('main');
|
|
|
|
const started = events.find(
|
|
(event) =>
|
|
event.type === 'turn.started' &&
|
|
event.agentId === agentId &&
|
|
event.origin.kind === 'user',
|
|
);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.started',
|
|
sessionId: session.id,
|
|
agentId,
|
|
origin: { kind: 'user' },
|
|
}),
|
|
);
|
|
expect(started?.agentId).not.toBe('main');
|
|
expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.spawned' }));
|
|
expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.completed' }));
|
|
expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.failed' }));
|
|
expect(events).not.toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'session.meta.updated',
|
|
}),
|
|
);
|
|
expect(fakeProviderState.calls[1]?.systemPrompt).toBe(
|
|
fakeProviderState.calls[0]?.systemPrompt,
|
|
);
|
|
const btwHistoryText = JSON.stringify(fakeProviderState.calls[1]?.history);
|
|
expect(btwHistoryText).toContain('main task context');
|
|
expect(btwHistoryText).toContain('What are you working on right now?');
|
|
|
|
const statePath = join(session.summary!.sessionDir, 'state.json');
|
|
const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record<string, unknown>;
|
|
expect(state['lastPrompt']).toBe('main task context');
|
|
expect(state['agents']).toMatchObject({ main: expect.any(Object) });
|
|
expect(state['agents']).not.toHaveProperty(agentId);
|
|
|
|
await harness.closeSession(session.id);
|
|
const resumed = await harness.resumeSession({ id: session.id });
|
|
const resumeState = resumed.getResumeState();
|
|
expect(resumeState?.agents).toMatchObject({ main: expect.any(Object) });
|
|
expect(resumeState?.agents).not.toHaveProperty(agentId);
|
|
expect(resumeState?.sessionMetadata.agents).not.toHaveProperty(agentId);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('rejects empty prompt input', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({ id: 'ses_empty_prompt', workDir });
|
|
await expect(session.prompt(' ')).rejects.toMatchObject({
|
|
name: 'KimiError',
|
|
code: 'request.prompt_input_empty',
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function configureFakeProvider(harness: KimiHarness): Promise<void> {
|
|
await harness.setConfig({
|
|
providers: {
|
|
local: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-test',
|
|
},
|
|
},
|
|
models: {
|
|
'fake-model': {
|
|
provider: 'local',
|
|
model: 'fake-model',
|
|
maxContextSize: 262144,
|
|
},
|
|
},
|
|
defaultModel: 'fake-model',
|
|
});
|
|
}
|
|
|
|
function waitForEvent(
|
|
session: {
|
|
onEvent(listener: (event: Event) => void): () => void;
|
|
},
|
|
predicate: (event: Event) => boolean,
|
|
): Promise<Event> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
unsubscribe();
|
|
reject(new Error('Timed out waiting for session event'));
|
|
}, 1_000);
|
|
const unsubscribe = session.onEvent((event) => {
|
|
if (!predicate(event)) return;
|
|
clearTimeout(timeout);
|
|
unsubscribe();
|
|
resolve(event);
|
|
});
|
|
});
|
|
}
|