Download packages/node-sdk/test/session-prompt-events.test.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 30.2 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/node-sdk/test/session-prompt-events.test.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/node-sdk/test/session-prompt-events.test.ts
-
curl -L -o session-prompt-events.test.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/node-sdk/test/session-prompt-events.test.ts
30.2 kB
| import { mkdtemp, readdir, 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | |
| import { createKimiHarness, type Event, type KimiHarness } from '#/index'; | |
| import { TEST_IDENTITY } from './test-identity'; | |
| const MODEL_URL = 'https://model.example.test/v1/chat/completions'; | |
| const fakeProviderState = vi.hoisted(() => ({ | |
| calls: [] as Array<{ | |
| readonly systemPrompt: string; | |
| readonly history: unknown; | |
| }>, | |
| responseText: 'hello from fake provider', | |
| })); | |
| function sseChunk(delta: Record<string, unknown>, finishReason: string | null = null): string { | |
| return `data: ${JSON.stringify({ | |
| id: 'chatcmpl-stub', | |
| object: 'chat.completion.chunk', | |
| created: 0, | |
| model: 'fake-model', | |
| choices: [{ index: 0, delta, finish_reason: finishReason }], | |
| })}`; | |
| } | |
| function sseBody(text: string): string { | |
| return [sseChunk({ role: 'assistant', content: text }), sseChunk({}, 'stop'), 'data: [DONE]'] | |
| .map((line) => `${line}\n\n`) | |
| .join(''); | |
| } | |
| let fetchStub: ReturnType<typeof vi.spyOn> | undefined; | |
| const tempDirs: string[] = []; | |
| beforeEach(() => { | |
| fakeProviderState.calls.length = 0; | |
| fakeProviderState.responseText = 'hello from fake provider'; | |
| fetchStub = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { | |
| const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; | |
| if (url !== MODEL_URL) { | |
| throw new Error(`Unexpected fetch: ${url}`); | |
| } | |
| const body = JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { | |
| readonly messages?: ReadonlyArray<{ readonly role: string; readonly content: unknown }>; | |
| }; | |
| const messages = body.messages ?? []; | |
| const system = messages.find((message) => message.role === 'system'); | |
| fakeProviderState.calls.push({ | |
| systemPrompt: typeof system?.content === 'string' ? system.content : '', | |
| history: messages, | |
| }); | |
| return new Response(sseBody(fakeProviderState.responseText), { | |
| status: 200, | |
| headers: { 'Content-Type': 'text/event-stream' }, | |
| }); | |
| }); | |
| }); | |
| afterEach(async () => { | |
| fetchStub?.mockRestore(); | |
| fetchStub = undefined; | |
| 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('continues notifying sessions after disabling and restarting with identical prompt and tools', async () => { | |
| vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); | |
| vi.stubEnv('KIMI_CODE_EXPERIMENTAL_NOTIFY_USER', ''); | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const requests: Array<{ tools: unknown; messages: Array<{ role: string; content: unknown }> }> = | |
| []; | |
| fetchStub!.mockImplementation( | |
| async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => { | |
| const url = | |
| typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; | |
| if (url !== MODEL_URL) throw new Error(`Unexpected fetch: ${url}`); | |
| if (typeof init?.body !== 'string') throw new Error('Expected a JSON request body'); | |
| requests.push(JSON.parse(init.body)); | |
| const body = | |
| requests.length % 2 === 1 | |
| ? [ | |
| sseChunk({ | |
| role: 'assistant', | |
| tool_calls: [ | |
| { | |
| index: 0, | |
| id: `notify-${requests.length}`, | |
| type: 'function', | |
| function: { | |
| name: 'NotifyUser', | |
| arguments: JSON.stringify({ message: 'Checking the work.' }), | |
| }, | |
| }, | |
| ], | |
| }), | |
| sseChunk({}, 'tool_calls'), | |
| 'data: [DONE]', | |
| ] | |
| .map((line) => `${line}\n\n`) | |
| .join('') | |
| : sseBody('Work completed.'); | |
| return new Response(body, { | |
| status: 200, | |
| headers: { 'Content-Type': 'text/event-stream' }, | |
| }); | |
| }, | |
| ); | |
| let harness = createKimiHarness({ | |
| identity: TEST_IDENTITY, | |
| homeDir, | |
| uiCapabilities: ['update_panel'], | |
| }); | |
| try { | |
| await configureFakeProvider(harness); | |
| await harness.setConfig({ experimental: { notify_user: true } }); | |
| let session = await harness.createSession({ id: 'ses_notify_continue', workDir }); | |
| const events: Event[] = []; | |
| let unsubscribe = session.onEvent((event) => events.push(event)); | |
| for (let turn = 0; turn < 3; turn++) { | |
| if (turn === 1) await harness.setConfig({ experimental: { notify_user: false } }); | |
| if (turn === 2) { | |
| unsubscribe(); | |
| await harness.close(); | |
| harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir, uiCapabilities: [] }); | |
| session = await harness.resumeSession({ id: 'ses_notify_continue' }); | |
| unsubscribe = session.onEvent((event) => events.push(event)); | |
| } | |
| const done = waitForEvent(session, (event) => event.type === 'turn.ended'); | |
| await session.prompt(`Continue the work, turn ${turn + 1}.`); | |
| expect(await done).toMatchObject({ reason: 'completed' }); | |
| } | |
| unsubscribe(); | |
| expect(requests).toHaveLength(6); | |
| const system = (request: (typeof requests)[number]) => | |
| request.messages.filter((message) => message.role === 'system'); | |
| for (const request of requests) { | |
| expect(request.tools).toEqual(requests[0]!.tools); | |
| expect(system(request)).toEqual(system(requests[0]!)); | |
| } | |
| const results = events.filter((event) => event.type === 'tool.result'); | |
| expect(results).toHaveLength(3); | |
| expect(results.every((event) => event.isError !== true)).toBe(true); | |
| expect(JSON.stringify(results[0])).toContain('Update shown to the user.'); | |
| expect(JSON.stringify(results.slice(1))).toContain( | |
| 'Notifications are disabled; the update was not displayed.', | |
| ); | |
| } finally { | |
| await harness.close(); | |
| vi.unstubAllEnvs(); | |
| } | |
| }); | |
| it('preserves existing custom metadata when an SDK metadata patch is resumed', 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_update_metadata', | |
| workDir, | |
| metadata: { source: 'vscode' }, | |
| }); | |
| await session.createGoal({ objective: 'Keep core-owned metadata' }); | |
| await session.updateMetadata({ | |
| vscode_legacy_approval: { yolo: true, afk: false }, | |
| }); | |
| await session.close(); | |
| const resumed = await harness.resumeSession({ id: session.id }); | |
| expect(resumed.summary?.metadata).toEqual({ | |
| source: 'vscode', | |
| vscode_legacy_approval: { yolo: true, afk: false }, | |
| }); | |
| await expect(resumed.getGoal()).resolves.toMatchObject({ | |
| goal: { objective: 'Keep core-owned metadata' }, | |
| }); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| 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; | |
| 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; | |
| 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(); | |
| 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'); | |
| } 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(JSON.stringify(fakeProviderState.calls[0]?.history)).toContain('Task requirements:'); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('carries the prompt on the public turn.started event for subagent system triggers', 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_v2', 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(events).toContainEqual( | |
| expect.objectContaining({ | |
| type: 'turn.started', | |
| sessionId: session.id, | |
| agentId: spawned?.type === 'subagent.spawned' ? spawned.subagentId : undefined, | |
| origin: { kind: 'system_trigger', name: 'subagent' }, | |
| prompt: expect.stringContaining('Task requirements:'), | |
| }), | |
| ); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('includes persisted subagent replay only when resume explicitly requests it', 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_subagent_replay', 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'); | |
| if (spawned?.type !== 'subagent.spawned') throw new Error('Expected persisted subagent'); | |
| await session.close(); | |
| const defaultResume = await harness.resumeSession({ id: session.id }); | |
| expect(defaultResume.getResumeState()?.agents).not.toHaveProperty(spawned.subagentId); | |
| await defaultResume.close(); | |
| const fullResume = await harness.resumeSession({ | |
| id: session.id, | |
| includeSubagents: true, | |
| }); | |
| expect(fullResume.getResumeState()?.agents[spawned.subagentId]?.replay).toContainEqual( | |
| expect.objectContaining({ | |
| type: 'message', | |
| message: expect.objectContaining({ role: 'assistant' }), | |
| }), | |
| ); | |
| } 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?'); | |
| 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); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('persists only conversation through the selected turn across resume', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| await configureFakeProvider(harness); | |
| const source = await harness.createSession({ id: 'ses_turn_fork_source', workDir }); | |
| await runPrompt(source, 'first question', 'first answer'); | |
| await runPrompt(source, 'second question', 'second answer'); | |
| await runPrompt(source, 'third question', 'third answer'); | |
| const fork = await harness.forkSession({ | |
| id: source.id, | |
| forkId: 'ses_turn_fork_child', | |
| turnIndex: 1, | |
| }); | |
| await fork.close(); | |
| const resumed = await harness.resumeSession({ id: fork.id }); | |
| const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); | |
| expect(replayText).toEqual([ | |
| 'user:first question', | |
| 'assistant:first answer', | |
| 'user:second question', | |
| 'assistant:second answer', | |
| ]); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('returns the requested identity and derives metadata from the selected historical turn', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| await configureFakeProvider(harness); | |
| const source = await harness.createSession({ | |
| id: 'ses_turn_fork_metadata_source', | |
| workDir, | |
| metadata: { source: 'vscode' }, | |
| }); | |
| await runPrompt(source, 'branch here', 'kept answer'); | |
| await runPrompt(source, 'future prompt', 'discarded answer'); | |
| const fork = await harness.forkSession({ | |
| id: source.id, | |
| forkId: 'ses_turn_fork_metadata_child', | |
| title: 'Historical branch', | |
| metadata: { branch: 'historical' }, | |
| turnIndex: 0, | |
| }); | |
| const state = fork.getResumeState(); | |
| expect(fork.id).toBe('ses_turn_fork_metadata_child'); | |
| expect(fork.workDir).toBe(source.workDir); | |
| expect(state?.sessionMetadata.forkedFrom).toBe(source.id); | |
| expect(fork.summary).toMatchObject({ | |
| title: 'Historical branch', | |
| lastPrompt: 'branch here', | |
| metadata: { source: 'vscode', branch: 'historical' }, | |
| }); | |
| expect(state?.sessionMetadata).toMatchObject({ | |
| title: 'Historical branch', | |
| lastPrompt: 'branch here', | |
| custom: { source: 'vscode', branch: 'historical' }, | |
| }); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('flattens the undo branch out of a turn-sliced fork', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| await configureFakeProvider(harness); | |
| const source = await harness.createSession({ id: 'ses_turn_fork_undo_source', workDir }); | |
| await runPrompt(source, 'kept prompt one', 'kept answer one'); | |
| await runPrompt(source, 'kept prompt two', 'kept answer two'); | |
| await runPrompt(source, 'retracted prompt', 'retracted answer'); | |
| await source.undoHistory(1); | |
| await runPrompt(source, 'follow-up prompt', 'follow-up answer'); | |
| const fork = await harness.forkSession({ | |
| id: source.id, | |
| forkId: 'ses_turn_fork_undo_child', | |
| turnIndex: 2, | |
| }); | |
| await fork.close(); | |
| const resumed = await harness.resumeSession({ id: fork.id }); | |
| const replayText = visibleReplayText(resumed.getResumeState()?.agents['main']?.replay ?? []); | |
| expect(replayText).toEqual([ | |
| 'user:kept prompt one', | |
| 'assistant:kept answer one', | |
| 'user:kept prompt two', | |
| 'assistant:kept answer two', | |
| 'user:follow-up prompt', | |
| 'assistant:follow-up answer', | |
| ]); | |
| const wireFiles = (await readdir(homeDir, { recursive: true })).filter((path) => | |
| path.endsWith(join('agents', 'main', 'wire.jsonl')), | |
| ); | |
| const forkedLines = (await readFile( | |
| join(homeDir, wireFiles.find((path) => path.includes(fork.id))!), | |
| 'utf8', | |
| )) | |
| .trimEnd() | |
| .split('\n'); | |
| const types = forkedLines.map((line) => (JSON.parse(line) as { type: string }).type); | |
| expect(types).not.toContain('agent.switched'); | |
| expect(types).not.toContain('context.undo'); | |
| expect(types).not.toContain('context.undone'); | |
| const sourceLines = (await readFile( | |
| join(homeDir, wireFiles.find((path) => path.includes(source.id))!), | |
| 'utf8', | |
| )) | |
| .trimEnd() | |
| .split('\n'); | |
| const sourceTypes = sourceLines.map((line) => (JSON.parse(line) as { type: string }).type); | |
| expect(sourceTypes).toContain('agent.switched'); | |
| expect(sourceLines.some((line) => line.includes('retracted'))).toBe(true); | |
| const retractedTypes = forkedLines | |
| .filter((line) => line.includes('retracted')) | |
| .map((line) => (JSON.parse(line) as { type: string }).type); | |
| expect(retractedTypes).toEqual([]); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('continues with the next turn id after a historical fork', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| await configureFakeProvider(harness); | |
| const source = await harness.createSession({ id: 'ses_turn_fork_id_source', workDir }); | |
| await runPrompt(source, 'kept prompt', 'kept answer'); | |
| await runPrompt(source, 'future prompt', 'future answer'); | |
| const fork = await harness.forkSession({ id: source.id, turnIndex: 0 }); | |
| const started = waitForEvent(fork, (event) => event.type === 'turn.started'); | |
| const ended = waitForEvent(fork, (event) => event.type === 'turn.ended'); | |
| await fork.prompt('branch continuation'); | |
| await expect(started).resolves.toMatchObject({ type: 'turn.started', turnId: 1 }); | |
| await ended; | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('omits subagents created after the selected historical turn', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| await configureFakeProvider(harness); | |
| const source = await harness.createSession({ id: 'ses_turn_fork_agents_source', workDir }); | |
| await runPrompt(source, 'kept prompt', 'kept answer'); | |
| await runPrompt(source, 'future prompt', 'future answer'); | |
| await source.init(); | |
| const fork = await harness.forkSession({ id: source.id, turnIndex: 0 }); | |
| expect(Object.keys(fork.getResumeState()?.sessionMetadata.agents ?? {})).toEqual(['main']); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('rejects a negative historical turn index with request.invalid', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| const source = await harness.createSession({ id: 'ses_turn_fork_negative', workDir }); | |
| await expect( | |
| harness.forkSession({ id: source.id, turnIndex: -1 }), | |
| ).rejects.toMatchObject({ | |
| name: 'KimiError', | |
| code: 'request.invalid', | |
| }); | |
| } finally { | |
| await harness.close(); | |
| } | |
| }); | |
| it('rejects an out-of-range historical turn without creating the fork', async () => { | |
| const homeDir = await makeTempDir(); | |
| const workDir = await makeTempDir(); | |
| const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); | |
| try { | |
| await configureFakeProvider(harness); | |
| const source = await harness.createSession({ id: 'ses_turn_fork_range_source', workDir }); | |
| await runPrompt(source, 'only question', 'only answer'); | |
| await expect( | |
| harness.forkSession({ | |
| id: source.id, | |
| forkId: 'ses_turn_fork_range_child', | |
| turnIndex: 1, | |
| }), | |
| ).rejects.toMatchObject({ | |
| name: 'KimiError', | |
| code: 'request.invalid', | |
| details: { turnIndex: 1, availableTurns: 1 }, | |
| }); | |
| await expect( | |
| harness.listSessions({ sessionId: 'ses_turn_fork_range_child' }), | |
| ).resolves.toEqual([]); | |
| } 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 runPrompt( | |
| session: Parameters<typeof waitForEvent>[0] & { prompt(input: string): Promise<void> }, | |
| input: string, | |
| response: string, | |
| ): Promise<void> { | |
| fakeProviderState.responseText = response; | |
| const done = waitForEvent(session, (event) => event.type === 'turn.ended'); | |
| await session.prompt(input); | |
| await done; | |
| } | |
| function visibleReplayText( | |
| records: readonly { | |
| readonly type: string; | |
| readonly message?: { | |
| readonly role: string; | |
| readonly content: ReadonlyArray<{ readonly type: string; readonly text?: string }>; | |
| readonly origin?: { readonly kind: string }; | |
| }; | |
| }[], | |
| ): readonly string[] { | |
| const entries: string[] = []; | |
| for (const record of records) { | |
| if (record.type !== 'message' || record.message === undefined) continue; | |
| const { message } = record; | |
| if (message.role === 'user' && message.origin?.kind !== 'user') continue; | |
| if (message.role !== 'user' && message.role !== 'assistant') continue; | |
| const text = message.content | |
| .filter((part) => part.type === 'text') | |
| .map((part) => part.text ?? '') | |
| .join(''); | |
| entries.push(`${message.role}:${text}`); | |
| } | |
| return entries; | |
| } | |
| async function configureFakeProvider(harness: KimiHarness): Promise<void> { | |
| await harness.setConfig({ | |
| providers: { | |
| local: { | |
| type: 'openai', | |
| baseUrl: 'https://model.example.test/v1', | |
| 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); | |
| }); | |
| }); | |
| } | |