// @vitest-environment node import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { HostApiContext } from '../../electron/api/context'; import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher'; import { createCodingComposition } from '../../electron/api/coding-composition'; import { isCodingProviderAuthenticationError } from '../../electron/api/coding-provider-auth'; import { handleCodingConversationRoutes } from '../../electron/api/routes/coding-conversations'; import type { AgentBrowserModule } from '../../electron/agent-browser'; import { CodingConversationService, } from '../../electron/coding-runtime/conversation-service'; import { CodingRuntimeContractError, InMemoryConversationRuntime, } from '../../electron/coding-runtime/in-memory-conversation-runtime'; import { CodingProjectService } from '../../electron/coding-projects/project-service'; import { createCodingProjectAgent, } from '../../electron/coding-projects/project-config'; import { createCodingProjectStore, createLocalCodingProject, createMemoryCodingProjectStorage, } from '../../electron/coding-projects/project-store'; import type { ConversationSnapshot, ConversationPatchEnvelope, PrepareConversationInput, PromptConversationInput, } from '../../electron/coding-runtime/contracts'; import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader'; import { PiProviderConfigError } from '../../electron/coding-runtime/pi/provider-config'; import type { ProductModelRef } from '../../shared/coding-conversation-contracts'; const roots: string[] = []; const servers: Server[] = []; const MODEL = { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' as const, }; afterEach(async () => { await Promise.all(servers.splice(0).map(async (server) => { await new Promise((resolve) => server.close(() => resolve())); })); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); async function setup(runtime = new InMemoryConversationRuntime({ commands: [{ name: 'live-command', description: 'From live worker' }], })) { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-core-')); roots.push(root); let projectSequence = 0; const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => projectSequence++ === 0 ? 'project-a' : `project-extra-${projectSequence}`, now: () => '2026-08-23T00:00:00.000Z', }); await createLocalCodingProject({ projectPath: root, now: '2026-08-23T00:00:00.000Z', }, store); await createCodingProjectAgent(root, { id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder', model: MODEL, modelResolution: 'resolved', responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], }, }, { now: '2026-08-23T00:00:00.000Z' }); const projects = new CodingProjectService(store); const conversations = new CodingConversationService(projects, runtime); return { root, store, projects, conversations, runtime }; } function context(setupResult: Awaited>): HostApiContext { return { codingProducts: { projects: setupResult.projects, conversations: setupResult.conversations, runtime: setupResult.runtime, }, } as unknown as HostApiContext; } async function createConversation( conversations: CodingConversationService, ) { return await conversations.createConversation({ agentId: 'builder', title: 'PI-100', }); } describe('PI-100 coding core Host contract', () => { it('names new conversations from the first real user event without a Renderer stream', async () => { const result = await setup(); result.conversations.dispose(); let publish!: (event: ConversationPatchEnvelope) => void; vi.spyOn(result.runtime, 'subscribe').mockImplementation((listener) => { publish = listener; return () => undefined; }); const service = new CodingConversationService(result.projects, result.runtime); const conversation = await service.createConversation({ agentId: 'builder', title: '新对话' }); await service.getSnapshot(conversation.id); const sendUser = (text: string, status: 'complete' | 'optimistic' = 'complete') => publish({ conversationId: conversation.id, workerGeneration: 1, seq: 1, at: 1, patch: { op: 'message.upsert', node: { kind: 'message', id: text, role: 'user', status, blocks: [{ kind: 'text', id: text, text, status: 'complete' }] } }, }); sendUser('未被接受的草稿', 'optimistic'); expect((await service.getConversation(conversation.id)).title).toBe('新对话'); sendUser('修复登录页\n这是第二行'); sendUser('后续消息'); await vi.waitFor(async () => expect((await service.getConversation(conversation.id)).title).toBe('修复登录页')); await service.patchConversation(conversation.id, { title: '我的标题' }); sendUser('再次更新'); expect((await service.getConversation(conversation.id)).title).toBe('我的标题'); expect(await service.getConversation(conversation.id)).not.toHaveProperty('titleMode'); service.dispose(); }); it('archives without stopping work, emits metadata, and guards only new prompts and forks', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'first', mode: 'prompt', text: 'Build' }); const dispose = vi.spyOn(result.runtime, 'dispose'); const abort = vi.spyOn(result.runtime, 'abort'); const stream = await result.conversations.openEventStream(); await result.conversations.patchConversation(conversation.id, { archived: true }); expect((await stream.events[Symbol.asyncIterator]().next()).value).toEqual({ type: 'conversation.metadata-changed', projectId: 'project-a', conversationId: conversation.id, }); expect(dispose).not.toHaveBeenCalled(); expect(abort).not.toHaveBeenCalled(); expect((await result.runtime.getSnapshot(conversation.id)).run.status).toBe('running'); for (const mode of ['prompt', 'steer', 'follow-up']) { await expect(result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: mode, mode, text: 'New work' })).rejects.toMatchObject({ code: 'CODING_CONVERSATION_ARCHIVED' }); } await expect(result.conversations.fork(conversation.id, 'user-entry')).rejects.toMatchObject({ code: 'CODING_CONVERSATION_ARCHIVED' }); await expect(result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'first', mode: 'prompt', text: 'Build' })).resolves.toMatchObject({ accepted: true }); await result.conversations.abort(conversation.id); expect(abort).toHaveBeenCalledOnce(); await result.conversations.patchConversation(conversation.id, { archived: false }); expect((await result.conversations.getConversation(conversation.id)).archivedAt).toBeNull(); stream.close(); }); it('uses one vendor-neutral Main composition without spawning on create', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-project-')); const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-user-')); roots.push(projectPath, userDataDir); const getLocalProxyCredential = vi.fn(() => 'host-token-after-server-start'); const composition = createCodingComposition({ storage: createMemoryCodingProjectStorage(), browser: { close: vi.fn(async () => undefined) } as unknown as AgentBrowserModule, getLocalProxyCredential, paths: { executablePath: process.execPath, cliPath: path.join(projectPath, 'unused-cli.js'), serverPath: path.resolve('resources/pi-agent-server.mjs'), userDataDir, bundledSkillsDir: path.resolve('resources/coding-skills'), }, }); try { const created = await composition.projects.createProject({ projectPath, identity: { kind: 'create' }, }); await createCodingProjectAgent(projectPath, { id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder', model: MODEL, modelResolution: 'resolved', responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], }, }); const conversation = await composition.conversations.createConversation({ projectId: created.project.id, agentId: 'builder', title: 'Local only', }); expect(composition.runtime.getDiagnostics().workers).toEqual([]); expect(getLocalProxyCredential).not.toHaveBeenCalled(); expect(await composition.host.listCommands(conversation.id)).toEqual( expect.arrayContaining([expect.objectContaining({ name: 'compact', source: 'makelore' })]), ); expect(composition.runtime.getDiagnostics().workers).toEqual([]); expect(getLocalProxyCredential).not.toHaveBeenCalled(); } finally { await composition.shutdown(); } }); it('keeps project and Conversation metadata operations local-only', async () => { const result = await setup(); const prepare = vi.spyOn(result.runtime, 'prepare'); const conversation = await createConversation(result.conversations); await result.conversations.listConversations('project-a'); await result.conversations.getConversation(conversation.id); await result.conversations.patchConversation(conversation.id, { title: 'Renamed' }); await result.conversations.deleteConversation(conversation.id); expect(prepare).not.toHaveBeenCalled(); expect(result.conversations.getDiagnostics().workers).toEqual([]); }); it('selects an unresolved model before preparing the Conversation', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-unresolved-')); roots.push(root); const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => 'project-unresolved', }); await createLocalCodingProject({ projectPath: root }, store); await createCodingProjectAgent(root, { id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder', model: null, modelResolution: 'required', responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [], }, }); class StrictResolvedModelRuntime extends InMemoryConversationRuntime { readonly prepareInputs: PrepareConversationInput[] = []; override async prepare(input: PrepareConversationInput) { this.prepareInputs.push(structuredClone(input)); if (input.model.modelResolution !== 'resolved' || !input.model.model) { throw new CodingRuntimeContractError( 'CODING_MIGRATION_MODEL_REQUIRED', 'A resolved model is required before preparation', true, ); } return await super.prepare(input); } } const runtime = new StrictResolvedModelRuntime(); const projects = new CodingProjectService(store); const conversations = new CodingConversationService(projects, runtime); const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' }); const prepare = vi.spyOn(runtime, 'prepare'); await expect(conversations.setModel(conversation.id, MODEL)).resolves.toEqual({ model: MODEL, modelResolution: 'resolved', }); expect(prepare).toHaveBeenCalledWith(expect.objectContaining({ conversationId: conversation.id, model: { model: MODEL, modelResolution: 'resolved' }, })); expect(runtime.prepareInputs).toHaveLength(1); expect(runtime.prepareInputs.every(({ model }) => ( model.modelResolution === 'resolved' && model.model !== null ))).toBe(true); await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({ model: MODEL, modelResolution: 'resolved', }); }); it('switches a removed saved model before preparing the Conversation', async () => { class RemovedModelRuntime extends InMemoryConversationRuntime { override async validateModel(model: ProductModelRef) { if (model.modelId === MODEL.modelId) { throw new PiProviderConfigError('MODEL_UNAVAILABLE', 'Model is no longer available'); } return await super.validateModel(model); } override async prepare(input: PrepareConversationInput) { if (input.model.model) await this.validateModel(input.model.model); return await super.prepare(input); } } const result = await setup(new RemovedModelRuntime()); const conversation = await createConversation(result.conversations); const nextModel = { ...MODEL, modelId: 'available-model' }; const prepare = vi.spyOn(result.runtime, 'prepare'); await expect(result.conversations.getSnapshot(conversation.id)).rejects.toMatchObject({ code: 'CODING_MODEL_UNAVAILABLE', status: 409, }); await expect(result.conversations.setModel(conversation.id, MODEL)).rejects.toMatchObject({ code: 'CODING_MODEL_UNAVAILABLE', status: 409, }); expect((await result.conversations.getConversation(conversation.id)).model).toEqual(MODEL); await expect(result.conversations.setModel(conversation.id, nextModel)).resolves.toMatchObject({ model: nextModel, modelResolution: 'resolved', }); expect(prepare).toHaveBeenLastCalledWith(expect.objectContaining({ model: { model: nextModel, modelResolution: 'resolved' }, })); expect(await result.projects.conversationStore(result.root).get(conversation.id)).toMatchObject({ id: conversation.id, model: nextModel, modelResolution: 'resolved', }); }); it('switches a resolved active Conversation model through the target runtime without disposing it', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'request-active-model-switch', mode: 'prompt', text: 'Keep this run active', attachments: [], }); await expect(result.runtime.getSnapshot(conversation.id)).resolves.toMatchObject({ run: { status: 'running' }, }); const setModel = vi.spyOn(result.runtime, 'setModel'); const dispose = vi.spyOn(result.runtime, 'dispose'); const nextModel = { ...MODEL, modelId: 'model-next' }; await expect(result.conversations.setModel(conversation.id, nextModel)).resolves.toEqual({ model: nextModel, modelResolution: 'resolved', }); expect(setModel).toHaveBeenCalledWith({ conversationId: conversation.id, accountId: nextModel.accountId, modelId: nextModel.modelId, }); expect(dispose).not.toHaveBeenCalled(); await expect(result.runtime.getSnapshot(conversation.id)).resolves.toMatchObject({ run: { status: 'running' }, }); await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toMatchObject({ model: nextModel, modelResolution: 'resolved', }); }); it('maps Conversation thinking metadata write failures to the stable storage error', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); await result.conversations.getSnapshot(conversation.id); const store = result.projects.conversationStore(result.root); vi.spyOn(result.projects, 'conversationStore').mockReturnValue(store); vi.spyOn(store, 'setModelState') .mockRejectedValueOnce(new Error('disk full')); await expect(result.conversations.setThinking(conversation.id, 'high')).rejects.toMatchObject({ status: 500, code: 'CODING_STORAGE_WRITE_FAILED', }); }); it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const sessionKey = 'session-delete'; await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ piSessionId: 'pi-session-delete', sessionKey, })); const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-trash-')); roots.push(userDataDir); const sourceDirectory = path.join( userDataDir, 'coding-runtime', 'pi', 'sessions', 'project-a', ); await mkdir(sourceDirectory, { recursive: true }); await writeFile(path.join(sourceDirectory, `${sessionKey}.jsonl`), 'session-data'); const conversations = new CodingConversationService(result.projects, result.runtime, { archiveSession: async (input) => { await archivePiConversationSession({ userDataDir, ...input }); }, }); const dispose = vi.spyOn(result.runtime, 'dispose'); await conversations.deleteConversation(conversation.id); expect(dispose).toHaveBeenCalledWith(conversation.id, 'conversation_deleted'); await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull(); await expect(readFile(path.join( userDataDir, 'coding-runtime', 'pi', 'trash', 'project-a', `${sessionKey}.jsonl`, ), 'utf8')).resolves.toBe('session-data'); }); it('preserves Conversation metadata when session archival fails', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ piSessionId: 'pi-session-preserved', sessionKey: 'session-preserved', })); const conversations = new CodingConversationService(result.projects, result.runtime, { archiveSession: async () => { throw new Error(`disk path=${result.root}`); }, }); await expect(conversations.deleteConversation(conversation.id)).rejects.toMatchObject({ status: 500, code: 'CODING_STORAGE_WRITE_FAILED', }); await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull(); }); it('returns 202 acceptance, deduplicates requests, and exposes only safe diagnostics', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const prompt = vi.spyOn(result.runtime, 'prompt'); const request = { path: `/api/coding/conversations/${conversation.id}/prompt`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ clientRequestId: 'request-1', mode: 'prompt', text: 'Implement it', attachments: [], }), }; const [first, duplicate] = await Promise.all([ dispatchHostApiRequest(context(result), request), dispatchHostApiRequest(context(result), request), ]); expect(first).toMatchObject({ status: 202, json: { acceptance: { accepted: true, clientRequestId: 'request-1', runId: expect.any(String), mode: 'prompt', }, }, }); expect(duplicate).toEqual(first); expect(prompt).toHaveBeenCalledTimes(1); const conflict = await dispatchHostApiRequest(context(result), { ...request, body: JSON.stringify({ clientRequestId: 'request-1', mode: 'prompt', text: 'Different', attachments: [], }), }); expect(conflict).toMatchObject({ status: 409, json: { code: 'CODING_REQUEST_ID_CONFLICT' }, }); const diagnostics = await dispatchHostApiRequest(context(result), { path: '/api/coding/runtime/diagnostics', }); expect(diagnostics).toMatchObject({ status: 200, json: { runtime: { revision: { provider: 1, resources: 1 } } }, }); expect(JSON.stringify(diagnostics.json)).not.toMatch(/session|workerId|apiKey|providerPath/i); }); it('opens an event stream snapshot-first and never replays a prompt', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const stream = await result.conversations.openEventStream(conversation.id); expect(stream.snapshots[0]).toMatchObject({ conversation: { id: conversation.id }, cursor: { workerGeneration: 0, seq: 0 }, }); await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'request-stream', mode: 'prompt', text: 'Stream this', attachments: [], }); const iterator = stream.events[Symbol.asyncIterator](); const firstPatch = await iterator.next(); expect(firstPatch).toMatchObject({ done: false, value: { type: 'patch-batch', conversationId: conversation.id, workerGeneration: 0, fromSeq: 1, }, }); if (firstPatch.done || firstPatch.value.type !== 'patch-batch') { throw new Error('Expected the first live patch-batch'); } expect(firstPatch.value.items.length).toBeGreaterThan(1); expect(firstPatch.value.items[0]).toMatchObject({ seq: 1, patch: { op: 'message.upsert' }, }); expect(firstPatch.value.items.map((item) => item.seq)).toEqual( Array.from( { length: firstPatch.value.toSeq - firstPatch.value.fromSeq + 1 }, (_, index) => firstPatch.value.fromSeq + index, ), ); stream.close(); const globalStream = await result.conversations.openEventStream(); expect(globalStream.snapshots.map((snapshot) => snapshot.conversation.id)).toContain(conversation.id); await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'request-global-stream', mode: 'follow-up', text: 'Keep streaming', attachments: [], }); await expect(globalStream.events[Symbol.asyncIterator]().next()).resolves.toMatchObject({ done: false, value: { type: 'patch-batch', conversationId: conversation.id }, }); globalStream.close(); }); it('publishes already-sequenced patches once in a 24 ms delivery batch', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const conversations = new CodingConversationService(result.projects, result.runtime, { deliveryBatchWindowMs: 24, }); const stream = await conversations.openEventStream(conversation.id); vi.useFakeTimers(); try { await conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'request-batch-window', mode: 'prompt', text: 'Batch this', attachments: [], }); const nextBatch = stream.events[Symbol.asyncIterator]().next(); let delivered = false; void nextBatch.then(() => { delivered = true; }); await vi.advanceTimersByTimeAsync(23); expect(delivered).toBe(false); await vi.advanceTimersByTimeAsync(1); const next = await nextBatch; expect(next.done).toBe(false); if (next.done || next.value.type !== 'patch-batch') { throw new Error('Expected a patch-batch'); } expect(next.value.items.length).toBeGreaterThan(1); expect(next.value.fromSeq).toBe(next.value.items[0].seq); expect(next.value.toSeq).toBe(next.value.items.at(-1)?.seq); expect(new Set(next.value.items.map((item) => item.seq)).size) .toBe(next.value.items.length); } finally { stream.close(); vi.useRealTimers(); } }); it('flushes a legal single-item batch at the configured item bound', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const conversations = new CodingConversationService(result.projects, result.runtime, { deliveryBatchWindowMs: 33, deliveryBatchMaxItems: 1, }); const stream = await conversations.openEventStream(conversation.id); await conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'request-single-item-batch', mode: 'prompt', text: 'Flush early', attachments: [], }); const next = await stream.events[Symbol.asyncIterator]().next(); expect(next).toMatchObject({ done: false, value: { type: 'patch-batch', fromSeq: 1, toSeq: 1, items: [{ seq: 1 }], }, }); stream.close(); }); it('cancels an old pending batch when a new worker generation publishes a snapshot', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const initial = await result.conversations.getSnapshot(conversation.id); const replacement = { ...initial, worker: { status: 'ready' as const, generation: initial.worker.generation + 1 }, cursor: { ...initial.cursor, workerGeneration: initial.cursor.workerGeneration + 1, seq: 1, }, }; let publish: ((event: ConversationPatchEnvelope) => void) | undefined; const originalSubscribe = result.runtime.subscribe.bind(result.runtime); vi.spyOn(result.runtime, 'subscribe').mockImplementation((listener) => { publish = listener; return originalSubscribe(listener); }); vi.spyOn(result.runtime, 'getSnapshot') .mockResolvedValueOnce(initial) .mockResolvedValue(replacement); const conversations = new CodingConversationService(result.projects, result.runtime, { deliveryBatchWindowMs: 24, }); const stream = await conversations.openEventStream(conversation.id); if (!publish) throw new Error('Runtime subscriber was not installed'); vi.useFakeTimers(); try { publish({ conversationId: conversation.id, workerGeneration: initial.cursor.workerGeneration, seq: 1, at: 1, patch: { op: 'run.state', run: { status: 'running', runId: 'old-run' } }, }); publish({ conversationId: conversation.id, workerGeneration: replacement.cursor.workerGeneration, seq: 1, at: 2, patch: { op: 'worker.state', state: replacement.worker }, }); const iterator = stream.events[Symbol.asyncIterator](); const next = iterator.next(); await vi.advanceTimersByTimeAsync(0); await expect(next).resolves.toMatchObject({ done: false, value: { type: 'snapshot', workerGeneration: replacement.cursor.workerGeneration, seq: 1, }, }); const afterReplacement = iterator.next(); let delivered = false; void afterReplacement.then(() => { delivered = true; }); await vi.advanceTimersByTimeAsync(24); expect(delivered).toBe(false); stream.close(); await expect(afterReplacement).resolves.toEqual({ value: undefined, done: true }); } finally { stream.close(); vi.useRealTimers(); } }); it('streams Host SSE snapshot before target patches', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const hostContext = context(result); const server = createServer((request, response) => { const url = new URL(request.url ?? '/', 'http://127.0.0.1'); void handleCodingConversationRoutes(request, response, url, hostContext).then((handled) => { if (!handled && !response.writableEnded) { response.statusCode = 404; response.end(); } }); }); servers.push(server); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); const address = server.address(); if (!address || typeof address === 'string') throw new Error('SSE test server did not bind'); const controller = new AbortController(); const response = await fetch( `http://127.0.0.1:${address.port}/api/coding/events?conversationId=${conversation.id}`, { signal: controller.signal }, ); expect(response.status).toBe(200); const reader = response.body?.getReader(); if (!reader) throw new Error('SSE response has no body'); const decoder = new TextDecoder(); let buffer = ''; const nextEvent = async (): Promise => { while (!buffer.includes('\n\n')) { const next = await reader.read(); if (next.done) throw new Error('SSE stream ended before the next event'); buffer += decoder.decode(next.value, { stream: true }); } const boundary = buffer.indexOf('\n\n'); const event = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); return event; }; expect(await nextEvent()).toContain('event: snapshot'); await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'request-host-sse', mode: 'prompt', text: 'Host to SSE', attachments: [], }); const patchEvent = await nextEvent(); expect(patchEvent).toContain('event: patch-batch'); expect(patchEvent).toContain(`"conversationId":"${conversation.id}"`); controller.abort(); await reader.cancel().catch(() => undefined); }); it('degrades live commands before worker prepare and projects them after prepare', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); expect(await result.conversations.listLiveCommands(conversation.id)).toEqual([]); await result.conversations.getSnapshot(conversation.id); expect(await result.conversations.listLiveCommands(conversation.id)).toEqual([ { name: 'live-command', description: 'From live worker' }, ]); }); it('correlates interaction responses by the route id', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); vi.spyOn(result.runtime, 'listInteractions').mockResolvedValue([{ id: 'question-1', conversationId: conversation.id, runId: 'run-1', kind: 'select', title: 'Choose', options: [{ id: 'option-1', label: 'One' }], status: 'pending', }]); const respond = vi.spyOn(result.runtime, 'respondInteraction').mockResolvedValue(); expect(await dispatchHostApiRequest(context(result), { path: `/api/coding/interactions?conversationId=${conversation.id}`, })).toMatchObject({ status: 200, json: { interactions: [{ id: 'question-1', status: 'pending' }] }, }); expect(await dispatchHostApiRequest(context(result), { path: '/api/coding/interactions/question-1/respond', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ conversationId: conversation.id, optionId: 'option-1' }), })).toMatchObject({ status: 204 }); expect(respond).toHaveBeenCalledWith(conversation.id, { interactionId: 'question-1', optionId: 'option-1', }); }); it('cleans the prior project for open, create, and explicit activation transitions', async () => { const projectRoots = await Promise.all(['a', 'b', 'c'].map(async (name) => { const root = await mkdtemp(path.join(tmpdir(), `makelore-pi-transition-${name}-`)); roots.push(root); return root; })); let nextId = 0; const store = createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => `project-${++nextId}`, }); const first = await createLocalCodingProject({ projectPath: projectRoots[0]! }, store); const deactivated: Array<{ projectId: string; reason: string }> = []; const projects = new CodingProjectService(store, { onProjectDeactivated: async (project, reason) => { deactivated.push({ projectId: project.id, reason }); }, }); const opened = await projects.openProject(projectRoots[1]!); const created = await projects.createProject({ projectPath: projectRoots[2]!, identity: { kind: 'create' }, }); await projects.setActiveProject(first.project.id); await projects.removeProject(first.project.id); expect(deactivated).toEqual([ { projectId: first.project.id, reason: 'project_deactivated' }, { projectId: opened.id, reason: 'project_deactivated' }, { projectId: created.project.id, reason: 'project_deactivated' }, { projectId: first.project.id, reason: 'project_removed' }, ]); }); it('removes absolute project roots from every core project response', async () => { const result = await setup(); const routeContext = context(result); const responses = [ await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects' }), await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects/active' }), await dispatchHostApiRequest(routeContext, { path: `/api/coding/projects/config?projectId=project-a`, }), await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects/open', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectPath: result.root }), }), ]; const createRoot = await mkdtemp(path.join(tmpdir(), 'makelore-pi-safe-project-')); roots.push(createRoot); responses.push(await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects/create', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectPath: createRoot, identity: { kind: 'create' } }), })); for (const response of responses) { expect(response.status).toBeLessThan(300); expect(JSON.stringify(response.json)).not.toContain(result.root); expect(JSON.stringify(response.json)).not.toContain(createRoot); expect(JSON.stringify(response.json)).not.toContain('"path"'); } }); it('maps persistence failures to a stable fixed Host error', async () => { const result = await setup(); const current = await result.projects.getConfig('project-a'); const failingProjects = new CodingProjectService(result.store, { writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); }, }); const failingConversations = new CodingConversationService(failingProjects, result.runtime); const response = await dispatchHostApiRequest(context({ ...result, projects: failingProjects, conversations: failingConversations, }), { path: '/api/coding/projects/config', method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'project-a', config: current.config }), }); expect(response).toMatchObject({ status: 500, json: { code: 'CODING_STORAGE_WRITE_FAILED', error: '本地数据写入失败,请检查存储后重试。', }, }); expect(JSON.stringify(response.json)).not.toContain(result.root); }); it('classifies production Provider authentication failures without exposing details', () => { expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: HTTP 401'))).toBe(true); expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: rate limited'))).toBe(false); }); it('redacts unknown runtime failures from Host responses', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const privateFailure = `stderr token=secret path=${result.root}`; vi.spyOn(result.runtime, 'getSnapshot').mockRejectedValue(new Error(privateFailure)); const response = await dispatchHostApiRequest(context(result), { path: `/api/coding/conversations/${conversation.id}/snapshot`, }); expect(response).toMatchObject({ status: 503, json: { code: 'CODING_RUNTIME_UNAVAILABLE', error: '本地编程运行时暂时不可用。', }, }); expect(JSON.stringify(response.json)).not.toContain('secret'); expect(JSON.stringify(response.json)).not.toContain(result.root); }); it('retains uncertain acceptance and never resends the same request id', async () => { class UncertainRuntime extends InMemoryConversationRuntime { uncertainCalls = 0; override async prompt(input: PromptConversationInput) { if (input.clientRequestId.startsWith('request-uncertain')) { this.uncertainCalls += 1; throw new CodingRuntimeContractError( 'CODING_REQUEST_UNCERTAIN', 'The local Agent did not confirm the request', true, ); } return { accepted: true as const, conversationId: input.conversationId, clientRequestId: input.clientRequestId, runId: `run-${input.clientRequestId}`, mode: input.mode, }; } } const runtime = new UncertainRuntime(); const result = await setup(runtime); const conversation = await createConversation(result.conversations); const input = { conversationId: conversation.id, clientRequestId: 'request-uncertain', mode: 'prompt', text: 'Do not resend', attachments: [], }; await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({ code: 'CODING_REQUEST_UNCERTAIN', }); const routeResponse = await dispatchHostApiRequest(context(result), { path: `/api/coding/conversations/${conversation.id}/prompt`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ clientRequestId: 'request-uncertain-route', mode: 'prompt', text: 'Still running', attachments: [], }), }); expect(routeResponse).toMatchObject({ status: 409, json: { code: 'CODING_REQUEST_UNCERTAIN', error: '请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。', }, }); expect(JSON.stringify(routeResponse.json)).not.toContain('本地编程运行时暂时不可用'); for (let index = 0; index < 512; index += 1) { await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: `request-settled-${index}`, mode: 'prompt', text: 'Settled', attachments: [], }); } await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({ code: 'CODING_REQUEST_UNCERTAIN', }); expect(runtime.uncertainCalls).toBe(2); }, 15_000); it('rejects model and fork mutations before persistence while confirmation is uncertain', async () => { class UncertainMutationRuntime extends InMemoryConversationRuntime { sourceConversationId = ''; readonly forkInputs: Parameters[0][] = []; override async getSnapshot(conversationId: string): Promise { const snapshot = await super.getSnapshot(conversationId); if (conversationId !== this.sourceConversationId) return snapshot; return { ...snapshot, nodes: [{ kind: 'message', id: 'node-user-uncertain', sourceEntryId: 'entry-user-uncertain', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'text-user-uncertain', text: 'Fork later', status: 'complete' }], }], run: { status: 'running', runId: 'run-uncertain', mode: 'prompt', startedAt: 1_000, error: { code: 'CODING_REQUEST_UNCERTAIN', message: '请求确认延迟,可能仍在执行。', recoverable: true, }, }, }; } override async fork(input: Parameters[0]) { this.forkInputs.push(structuredClone(input)); return await super.fork(input); } } const runtime = new UncertainMutationRuntime(); const result = await setup(runtime); const source = await createConversation(result.conversations); runtime.sourceConversationId = source.id; await result.conversations.getSnapshot(source.id); const store = result.projects.conversationStore(result.root); const create = vi.spyOn(store, 'create'); const setModelState = vi.spyOn(store, 'setModelState'); create.mockClear(); setModelState.mockClear(); await expect(result.conversations.setModel(source.id, { ...MODEL, modelId: 'model-next', })).rejects.toMatchObject({ status: 409, code: 'CODING_REQUEST_UNCERTAIN', }); await expect(result.conversations.fork(source.id, 'entry-user-uncertain')).rejects.toMatchObject({ status: 409, code: 'CODING_REQUEST_UNCERTAIN', }); expect(setModelState).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled(); expect(runtime.forkInputs).toEqual([]); await expect(store.read()).resolves.toMatchObject({ conversations: [expect.objectContaining({ id: source.id, model: MODEL })], }); }); it('disposes and archives a partially created fork before metadata rollback', async () => { let bindFork: ((conversationId: string) => Promise) | undefined; let forkTargetId = ''; class FailingForkRuntime extends InMemoryConversationRuntime { override async getSnapshot(conversationId: string): Promise { const snapshot = await super.getSnapshot(conversationId); return { ...snapshot, nodes: [{ kind: 'message', id: 'node-user-fork-cleanup', sourceEntryId: 'entry-user-fork-cleanup', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'text-user-fork-cleanup', text: 'Fork before hydration fails', status: 'complete', }], }], cursor: { ...snapshot.cursor, leafEntryId: 'entry-user-fork-cleanup' }, }; } override async fork(input: Parameters[0]): Promise { forkTargetId = input.conversation.conversationId; await bindFork?.(forkTargetId); throw new CodingRuntimeContractError( 'CODING_SESSION_UNREADABLE', 'Fork hydration failed', true, ); } } const runtime = new FailingForkRuntime(); const result = await setup(runtime); const store = result.projects.conversationStore(result.root); bindFork = async (conversationId) => { await store.ensureSessionBinding(conversationId, async () => ({ piSessionId: 'fork-session', sessionKey: 'fork-session-key', })); }; const archiveSession = vi.fn(async () => undefined); const conversations = new CodingConversationService(result.projects, runtime, { archiveSession }); const source = await createConversation(conversations); await conversations.getSnapshot(source.id); const dispose = vi.spyOn(runtime, 'dispose'); await expect(conversations.fork(source.id, 'entry-user-fork-cleanup')).rejects.toMatchObject({ code: 'CODING_SESSION_UNREADABLE', }); expect(dispose).toHaveBeenCalledWith(forkTargetId, 'fork_replacement'); expect(archiveSession).toHaveBeenCalledWith({ projectId: 'project-a', sessionKey: 'fork-session-key', }); await expect(store.get(forkTargetId)).resolves.toBeNull(); }); it('rejects non-user or inactive fork entries before creating target resources', async () => { class ForkSourceRuntime extends InMemoryConversationRuntime { sourceConversationId = ''; readonly forkInputs: Parameters[0][] = []; override async getSnapshot(conversationId: string): Promise { const snapshot = await super.getSnapshot(conversationId); if (conversationId !== this.sourceConversationId) return snapshot; return { ...snapshot, nodes: [ { kind: 'message', id: 'node-user-active', sourceEntryId: 'entry-user-active', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'text-user-active', text: 'Fork here', status: 'complete' }], }, { kind: 'message', id: 'node-assistant-active', sourceEntryId: 'entry-assistant-active', role: 'assistant', status: 'complete', blocks: [{ kind: 'text', id: 'text-assistant-active', text: 'Do not fork here', status: 'complete' }], }, ], cursor: { ...snapshot.cursor, leafEntryId: 'entry-assistant-active' }, }; } override async fork(input: Parameters[0]) { this.forkInputs.push(structuredClone(input)); await this.prepare(input.conversation); return { conversationId: input.conversation.conversationId, snapshot: await super.getSnapshot(input.conversation.conversationId), }; } } const runtime = new ForkSourceRuntime(); const result = await setup(runtime); const source = await createConversation(result.conversations); runtime.sourceConversationId = source.id; await result.conversations.getSnapshot(source.id); const store = result.projects.conversationStore(result.root); const create = vi.spyOn(store, 'create'); create.mockClear(); const dispose = vi.spyOn(runtime, 'dispose'); const archiveSession = vi.fn(async () => undefined); const conversations = new CodingConversationService(result.projects, runtime, { archiveSession }); for (const sourceEntryId of [ 'entry-assistant-active', 'entry-unknown', 'entry-user-stale', ]) { await expect(conversations.fork(source.id, sourceEntryId)).rejects.toMatchObject({ status: 400, code: 'CODING_CONVERSATION_REQUEST_INVALID', }); } expect(create).not.toHaveBeenCalled(); expect(runtime.forkInputs).toEqual([]); expect(dispose).not.toHaveBeenCalled(); expect(archiveSession).not.toHaveBeenCalled(); await expect(store.read()).resolves.toMatchObject({ conversations: [expect.objectContaining({ id: source.id })], }); const invalidResponse = await dispatchHostApiRequest(context({ ...result, conversations, }), { path: `/api/coding/conversations/${source.id}/fork`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sourceEntryId: 'entry-assistant-active' }), }); expect(invalidResponse).toMatchObject({ status: 400, json: { code: 'CODING_CONVERSATION_REQUEST_INVALID', error: '对话请求无效,请检查输入。', }, }); expect(create).not.toHaveBeenCalled(); const forked = await conversations.fork(source.id, 'entry-user-active'); expect(forked.agentId).toBe(source.agentId); await expect(store.read()).resolves.toMatchObject({ conversations: [ expect.objectContaining({ id: forked.id, agentId: source.agentId }), expect.objectContaining({ id: source.id, agentId: source.agentId }), ], }); expect(runtime.forkInputs).toHaveLength(1); expect(runtime.forkInputs[0]).toMatchObject({ sourceConversationId: source.id, sourceEntryId: 'entry-user-active', }); }); });