fix(coding): close PI-100 review findings
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
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';
|
||||
@@ -8,6 +8,7 @@ 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 {
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
createMemoryCodingProjectStorage,
|
||||
} from '../../electron/coding-projects/project-store';
|
||||
import type { PromptConversationInput } from '../../electron/coding-runtime/contracts';
|
||||
import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader';
|
||||
|
||||
const roots: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
@@ -48,8 +50,9 @@ async function setup(runtime = new InMemoryConversationRuntime({
|
||||
})) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-core-'));
|
||||
roots.push(root);
|
||||
let projectSequence = 0;
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-a',
|
||||
createId: () => projectSequence++ === 0 ? 'project-a' : `project-extra-${projectSequence}`,
|
||||
now: () => '2026-08-23T00:00:00.000Z',
|
||||
});
|
||||
await createLocalCodingProject({
|
||||
@@ -69,7 +72,7 @@ async function setup(runtime = new InMemoryConversationRuntime({
|
||||
}, { now: '2026-08-23T00:00:00.000Z' });
|
||||
const projects = new CodingProjectService(store);
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
return { root, projects, conversations, runtime };
|
||||
return { root, store, projects, conversations, runtime };
|
||||
}
|
||||
|
||||
function context(setupResult: Awaited<ReturnType<typeof setup>>): HostApiContext {
|
||||
@@ -146,6 +149,100 @@ describe('PI-100 coding core Host contract', () => {
|
||||
expect(result.conversations.getDiagnostics().workers).toEqual([]);
|
||||
});
|
||||
|
||||
it('selects an unresolved model before preparing the Conversation', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-unresolved-'));
|
||||
roots.push(root);
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-unresolved',
|
||||
});
|
||||
await createLocalCodingProject({ projectPath: root }, store);
|
||||
await createCodingProjectAgent(root, {
|
||||
id: 'builder',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: '实现者',
|
||||
name: 'Builder',
|
||||
model: null,
|
||||
modelResolution: 'required',
|
||||
responsibility: {
|
||||
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
});
|
||||
const runtime = new InMemoryConversationRuntime();
|
||||
const projects = new CodingProjectService(store);
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' });
|
||||
const prepare = vi.spyOn(runtime, 'prepare');
|
||||
|
||||
await expect(conversations.setModel(conversation.id, MODEL)).resolves.toEqual({
|
||||
model: MODEL,
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
expect(prepare).toHaveBeenCalledWith(expect.objectContaining({
|
||||
conversationId: conversation.id,
|
||||
model: { model: MODEL, modelResolution: 'resolved' },
|
||||
}));
|
||||
await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({
|
||||
model: MODEL,
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
const sessionKey = 'session-delete';
|
||||
await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({
|
||||
piSessionId: 'pi-session-delete',
|
||||
sessionKey,
|
||||
}));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-trash-'));
|
||||
roots.push(userDataDir);
|
||||
const sourceDirectory = path.join(
|
||||
userDataDir,
|
||||
'coding-runtime',
|
||||
'pi',
|
||||
'sessions',
|
||||
'project-a',
|
||||
);
|
||||
await mkdir(sourceDirectory, { recursive: true });
|
||||
await writeFile(path.join(sourceDirectory, `${sessionKey}.jsonl`), 'session-data');
|
||||
const conversations = new CodingConversationService(result.projects, result.runtime, {
|
||||
archiveSession: async (input) => {
|
||||
await archivePiConversationSession({ userDataDir, ...input });
|
||||
},
|
||||
});
|
||||
|
||||
await conversations.deleteConversation(conversation.id);
|
||||
|
||||
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull();
|
||||
await expect(readFile(path.join(
|
||||
userDataDir,
|
||||
'coding-runtime',
|
||||
'pi',
|
||||
'trash',
|
||||
'project-a',
|
||||
`${sessionKey}.jsonl`,
|
||||
), 'utf8')).resolves.toBe('session-data');
|
||||
});
|
||||
|
||||
it('preserves Conversation metadata when session archival fails', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({
|
||||
piSessionId: 'pi-session-preserved',
|
||||
sessionKey: 'session-preserved',
|
||||
}));
|
||||
const conversations = new CodingConversationService(result.projects, result.runtime, {
|
||||
archiveSession: async () => { throw new Error(`disk path=${result.root}`); },
|
||||
});
|
||||
|
||||
await expect(conversations.deleteConversation(conversation.id)).rejects.toMatchObject({
|
||||
status: 500,
|
||||
code: 'CODING_STORAGE_WRITE_FAILED',
|
||||
});
|
||||
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 202 acceptance, deduplicates requests, and exposes only safe diagnostics', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
@@ -338,6 +435,95 @@ describe('PI-100 coding core Host contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans the prior project for open, create, and explicit activation transitions', async () => {
|
||||
const projectRoots = await Promise.all(['a', 'b', 'c'].map(async (name) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), `makelore-pi-transition-${name}-`));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}));
|
||||
let nextId = 0;
|
||||
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => `project-${++nextId}`,
|
||||
});
|
||||
const first = await createLocalCodingProject({ projectPath: projectRoots[0]! }, store);
|
||||
const deactivated: string[] = [];
|
||||
const projects = new CodingProjectService(store, {
|
||||
onProjectDeactivated: async (project) => { deactivated.push(project.id); },
|
||||
});
|
||||
|
||||
const opened = await projects.openProject(projectRoots[1]!);
|
||||
const created = await projects.createProject({ projectPath: projectRoots[2]! });
|
||||
await projects.setActiveProject(first.project.id);
|
||||
|
||||
expect(deactivated).toEqual([first.project.id, opened.id, created.project.id]);
|
||||
});
|
||||
|
||||
it('removes absolute project roots from every core project response', async () => {
|
||||
const result = await setup();
|
||||
const routeContext = context(result);
|
||||
const responses = [
|
||||
await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects' }),
|
||||
await dispatchHostApiRequest(routeContext, { path: '/api/coding/projects/active' }),
|
||||
await dispatchHostApiRequest(routeContext, {
|
||||
path: `/api/coding/projects/config?projectId=project-a`,
|
||||
}),
|
||||
await dispatchHostApiRequest(routeContext, {
|
||||
path: '/api/coding/projects/open',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath: result.root }),
|
||||
}),
|
||||
];
|
||||
const createRoot = await mkdtemp(path.join(tmpdir(), 'makelore-pi-safe-project-'));
|
||||
roots.push(createRoot);
|
||||
responses.push(await dispatchHostApiRequest(routeContext, {
|
||||
path: '/api/coding/projects/create',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath: createRoot }),
|
||||
}));
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBeLessThan(300);
|
||||
expect(JSON.stringify(response.json)).not.toContain(result.root);
|
||||
expect(JSON.stringify(response.json)).not.toContain(createRoot);
|
||||
expect(JSON.stringify(response.json)).not.toContain('"path"');
|
||||
}
|
||||
});
|
||||
|
||||
it('maps persistence failures to a stable fixed Host error', async () => {
|
||||
const result = await setup();
|
||||
const failingProjects = new CodingProjectService(result.store, {
|
||||
writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); },
|
||||
});
|
||||
const failingConversations = new CodingConversationService(failingProjects, result.runtime);
|
||||
const current = await failingProjects.getConfig('project-a');
|
||||
const response = await dispatchHostApiRequest(context({
|
||||
...result,
|
||||
projects: failingProjects,
|
||||
conversations: failingConversations,
|
||||
}), {
|
||||
path: '/api/coding/projects/config',
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: 'project-a', config: current.config }),
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 500,
|
||||
json: {
|
||||
code: 'CODING_STORAGE_WRITE_FAILED',
|
||||
error: '本地数据写入失败,请检查存储后重试。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(result.root);
|
||||
});
|
||||
|
||||
it('classifies production Provider authentication failures without exposing details', () => {
|
||||
expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: HTTP 401'))).toBe(true);
|
||||
expect(isCodingProviderAuthenticationError(new Error('Pi RPC prompt failed: rate limited'))).toBe(false);
|
||||
});
|
||||
|
||||
it('redacts unknown runtime failures from Host responses', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
@@ -350,7 +536,7 @@ describe('PI-100 coding core Host contract', () => {
|
||||
status: 503,
|
||||
json: {
|
||||
code: 'CODING_RUNTIME_UNAVAILABLE',
|
||||
error: 'The local coding runtime is unavailable',
|
||||
error: '本地编程运行时暂时不可用。',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain('secret');
|
||||
@@ -359,15 +545,24 @@ describe('PI-100 coding core Host contract', () => {
|
||||
|
||||
it('retains uncertain acceptance and never resends the same request id', async () => {
|
||||
class UncertainRuntime extends InMemoryConversationRuntime {
|
||||
calls = 0;
|
||||
uncertainCalls = 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,
|
||||
);
|
||||
override async prompt(input: PromptConversationInput) {
|
||||
if (input.clientRequestId === 'request-uncertain') {
|
||||
this.uncertainCalls += 1;
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_REQUEST_UNCERTAIN',
|
||||
'The local Agent did not confirm the request',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return {
|
||||
accepted: true as const,
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
runId: `run-${input.clientRequestId}`,
|
||||
mode: input.mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
const runtime = new UncertainRuntime();
|
||||
@@ -383,9 +578,58 @@ describe('PI-100 coding core Host contract', () => {
|
||||
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
|
||||
code: 'CODING_REQUEST_UNCERTAIN',
|
||||
});
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
await result.conversations.acceptPrompt({
|
||||
conversationId: conversation.id,
|
||||
clientRequestId: `request-settled-${index}`,
|
||||
mode: 'prompt',
|
||||
text: 'Settled',
|
||||
attachments: [],
|
||||
});
|
||||
}
|
||||
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
|
||||
code: 'CODING_REQUEST_UNCERTAIN',
|
||||
});
|
||||
expect(runtime.calls).toBe(1);
|
||||
expect(runtime.uncertainCalls).toBe(1);
|
||||
}, 15_000);
|
||||
|
||||
it('disposes and archives a partially created fork before metadata rollback', async () => {
|
||||
let bindFork: ((conversationId: string) => Promise<void>) | undefined;
|
||||
let forkTargetId = '';
|
||||
class FailingForkRuntime extends InMemoryConversationRuntime {
|
||||
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]): Promise<never> {
|
||||
forkTargetId = input.conversation.conversationId;
|
||||
await bindFork?.(forkTargetId);
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_SESSION_UNREADABLE',
|
||||
'Fork hydration failed',
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const runtime = new FailingForkRuntime();
|
||||
const result = await setup(runtime);
|
||||
const store = result.projects.conversationStore(result.root);
|
||||
bindFork = async (conversationId) => {
|
||||
await store.ensureSessionBinding(conversationId, async () => ({
|
||||
piSessionId: 'fork-session',
|
||||
sessionKey: 'fork-session-key',
|
||||
}));
|
||||
};
|
||||
const archiveSession = vi.fn(async () => undefined);
|
||||
const conversations = new CodingConversationService(result.projects, runtime, { archiveSession });
|
||||
const source = await createConversation(conversations);
|
||||
await conversations.getSnapshot(source.id);
|
||||
const dispose = vi.spyOn(runtime, 'dispose');
|
||||
|
||||
await expect(conversations.fork(source.id)).rejects.toMatchObject({
|
||||
code: 'CODING_SESSION_UNREADABLE',
|
||||
});
|
||||
expect(dispose).toHaveBeenCalledWith(forkTargetId);
|
||||
expect(archiveSession).toHaveBeenCalledWith({
|
||||
projectId: 'project-a',
|
||||
sessionKey: 'fork-session-key',
|
||||
});
|
||||
await expect(store.get(forkTargetId)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('PI-105 coding product routes', () => {
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
status: 404,
|
||||
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' },
|
||||
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: '指定的项目文件不存在。' },
|
||||
});
|
||||
expect(JSON.stringify(response.json)).not.toContain(absolute);
|
||||
});
|
||||
|
||||
@@ -133,7 +133,10 @@ describe('Pi runtime Provider authentication recovery', () => {
|
||||
text: 'Do not leak credentials',
|
||||
attachments: [],
|
||||
})).rejects.toMatchObject({
|
||||
code: 'PI_RPC_RESPONSE_ERROR',
|
||||
publicError: {
|
||||
code: 'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => workers.length).toBe(2);
|
||||
|
||||
Reference in New Issue
Block a user