feat(coding): add core host api composition
This commit is contained in:
391
tests/unit/coding-core-routes.test.ts
Normal file
391
tests/unit/coding-core-routes.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } 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 { 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';
|
||||
|
||||
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);
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-a',
|
||||
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, 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('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('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: 'The local coding runtime is unavailable',
|
||||
},
|
||||
});
|
||||
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 {
|
||||
calls = 0;
|
||||
|
||||
override async prompt(_input: PromptConversationInput): Promise<never> {
|
||||
this.calls += 1;
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_REQUEST_UNCERTAIN',
|
||||
'The local Agent did not confirm the request',
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
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',
|
||||
});
|
||||
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
|
||||
code: 'CODING_REQUEST_UNCERTAIN',
|
||||
});
|
||||
expect(runtime.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user