1094 lines
42 KiB
TypeScript
1094 lines
42 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';
|
|
|
|
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 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 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',
|
|
});
|
|
});
|
|
});
|