Files
makelore/tests/unit/coding-core-routes.test.ts
brother7 0f7093d173 fix: allow replacing unavailable conversation models
Persist the selected model before opening dormant sessions, and reconfigure crashed workers on the same account. Preserve session history and report model removal through the model-unavailable contract instead of a generic runtime failure.
2026-09-21 12:23:04 +08:00

1188 lines
47 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 {
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<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('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<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-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<InMemoryConversationRuntime['fork']>[0][] = [];
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
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<InMemoryConversationRuntime['fork']>[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<void>) | undefined;
let forkTargetId = '';
class FailingForkRuntime extends InMemoryConversationRuntime {
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
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<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, '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<InMemoryConversationRuntime['fork']>[0][] = [];
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
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<InMemoryConversationRuntime['fork']>[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',
});
});
});