// @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 { PromptConversationInput } from '../../electron/coding-runtime/contracts'; import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader'; 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('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 composition = createCodingComposition({ storage: createMemoryCodingProjectStorage(), browser: { close: vi.fn(async () => undefined) } as unknown as AgentBrowserModule, paths: { executablePath: process.execPath, cliPath: path.join(projectPath, 'unused-cli.js'), userDataDir, bundledSkillsDir: path.resolve('resources/coding-skills'), }, }); try { const created = await composition.projects.createProject({ projectPath }); 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(await composition.host.listCommands(conversation.id)).toEqual( expect.arrayContaining([expect.objectContaining({ name: 'compact', source: 'makelore' })]), ); expect(composition.runtime.getDiagnostics().workers).toEqual([]); } 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: [], }, }); const runtime = new InMemoryConversationRuntime(); 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' }, })); await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({ model: MODEL, 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 }); }, }); await conversations.deleteConversation(conversation.id); 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', conversationId: conversation.id, workerGeneration: 0, seq: 1, patch: { op: 'message.upsert' }, }, }); 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', conversationId: conversation.id }, }); globalStream.close(); }); 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'); 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: string[] = []; const projects = new CodingProjectService(store, { onProjectDeactivated: async (project) => { deactivated.push(project.id); }, }); const opened = await projects.openProject(projectRoots[1]!); const created = await projects.createProject({ projectPath: projectRoots[2]! }); await projects.setActiveProject(first.project.id); expect(deactivated).toEqual([first.project.id, opened.id, created.project.id]); }); 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 }), })); 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 failingProjects = new CodingProjectService(result.store, { writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); }, }); const failingConversations = new CodingConversationService(failingProjects, result.runtime); const current = await failingProjects.getConfig('project-a'); 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 === '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', }); 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(1); }, 15_000); 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 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)).rejects.toMatchObject({ code: 'CODING_SESSION_UNREADABLE', }); expect(dispose).toHaveBeenCalledWith(forkTargetId); expect(archiveSession).toHaveBeenCalledWith({ projectId: 'project-a', sessionKey: 'fork-session-key', }); await expect(store.get(forkTargetId)).resolves.toBeNull(); }); });