Files
makelore/tests/unit/coding-core-routes.test.ts

687 lines
27 KiB
TypeScript

// @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<void>((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<ReturnType<typeof setup>>): 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<void>((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<string> => {
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<void>) | undefined;
let forkTargetId = '';
class FailingForkRuntime extends InMemoryConversationRuntime {
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]): Promise<never> {
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();
});
});