feat(coding): add PI-105 product file host API

This commit is contained in:
2026-08-23 18:17:30 +08:00
parent 7680bfb0cf
commit 9520872d2f
17 changed files with 1419 additions and 38 deletions

View File

@@ -0,0 +1,41 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { CodingProductHost } from '../../electron/api/coding-product-services';
import type { HostApiContext } from '../../electron/api/context';
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
import { CodingProjectFileService } from '../../electron/coding-projects/project-files';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('PI-105 Electron Host API seam', () => {
it('reads a project-relative file through the Main dispatcher without loopback or path leakage', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-host-dispatch-'));
roots.push(root);
await writeFile(path.join(root, 'notes.txt'), 'Main-owned content\n', 'utf8');
const files = new CodingProjectFileService();
const host = {
fileContent: async (filePath: string) => await files.content(root, filePath),
} as CodingProductHost;
const ctx = {
codingProducts: { host, attachments: {}, productTools: {} },
} as HostApiContext;
const response = await dispatchHostApiRequest(ctx, {
path: '/api/coding/files/content?path=notes.txt',
});
expect(response).toMatchObject({
status: 200,
ok: true,
json: {
file: { path: 'notes.txt', content: 'Main-owned content\n', truncated: false },
},
});
expect(JSON.stringify(response.json)).not.toContain(root);
});
});

View File

@@ -0,0 +1,102 @@
// @vitest-environment node
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import type { CodingProductHost } from '../../electron/api/coding-product-services';
import type { HostApiContext } from '../../electron/api/context';
import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher';
function context(host?: CodingProductHost): HostApiContext {
return (host
? { codingProducts: { host, attachments: {}, productTools: {} } }
: {}) as HostApiContext;
}
function productHost(): CodingProductHost {
return {
fileStatus: vi.fn(async () => [{
path: 'src/app.ts', name: 'app.ts', type: 'file' as const, status: 'modified' as const,
}]),
findFiles: vi.fn(async () => [{
path: 'src/app.ts', name: 'app.ts', type: 'file' as const, size: 12,
}]),
fileContent: vi.fn(async () => ({
path: 'src/app.ts', content: 'export {};\n', truncated: false,
})),
searchText: vi.fn(async () => [{
path: 'src/app.ts', name: 'app.ts', lineNumber: 1, lineText: 'export {};', submatches: [],
}]),
listSkills: vi.fn(async () => [{
id: 'agent-browser' as const, name: 'Agent Browser', description: 'Browser', selected: true,
}]),
listCommands: vi.fn(async () => [{
name: 'compact', title: '压缩会话', description: 'Compact', source: 'makelore' as const,
}]),
getChanges: vi.fn(async () => ({
conversationId: 'conversation/a', runId: 'run-a', git: false, baselineHead: null, files: [],
})),
};
}
describe('PI-105 coding product routes', () => {
it('dispatches the seven vendor-neutral GET routes with stable DTOs', async () => {
const host = productHost();
const cases = [
['/api/coding/files/status', { files: [expect.objectContaining({ path: 'src/app.ts' })] }],
['/api/coding/files/find?query=app&limit=2', { files: [expect.objectContaining({ size: 12 })] }],
['/api/coding/files/content?path=src%2Fapp.ts', { file: expect.objectContaining({ content: 'export {};\n' }) }],
['/api/coding/search?pattern=export', { matches: [expect.objectContaining({ lineNumber: 1 })] }],
['/api/coding/skills?agentId=builder', { skills: [expect.objectContaining({ selected: true })] }],
['/api/coding/conversations/conversation%2Fa/commands', {
commands: [expect.objectContaining({ name: 'compact', source: 'makelore' })],
}],
['/api/coding/conversations/conversation%2Fa/changes', {
changes: expect.objectContaining({ conversationId: 'conversation/a', runId: 'run-a' }),
}],
] as const;
for (const [route, expected] of cases) {
const response = await dispatchHostApiRequest(context(host), { path: route });
expect(response).toMatchObject({ status: 200, ok: true, json: expected });
const serialized = JSON.stringify(response.json);
expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/);
}
expect(host.findFiles).toHaveBeenCalledWith('app', 2);
expect(host.fileContent).toHaveBeenCalledWith('src/app.ts');
expect(host.listSkills).toHaveBeenCalledWith('builder');
expect(host.listCommands).toHaveBeenCalledWith('conversation/a');
expect(host.getChanges).toHaveBeenCalledWith('conversation/a');
});
it('reports unavailable composition and redacts absolute-path service failures', async () => {
expect(await dispatchHostApiRequest(context(), { path: '/api/coding/files/status' }))
.toMatchObject({
status: 503,
json: { success: false, code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE' },
});
const host = productHost();
const absolute = path.resolve('private', 'secret.txt');
vi.mocked(host.fileContent).mockRejectedValueOnce(
Object.assign(new Error(`ENOENT: ${absolute}`), { code: 'ENOENT' }),
);
const response = await dispatchHostApiRequest(context(host), {
path: '/api/coding/files/content?path=missing.txt',
});
expect(response).toMatchObject({
status: 404,
json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' },
});
expect(JSON.stringify(response.json)).not.toContain(absolute);
});
it('does not claim non-GET methods or unrelated coding routes', async () => {
expect(await dispatchHostApiRequest(context(productHost()), {
path: '/api/coding/files/status', method: 'POST',
headers: { 'content-type': 'application/json' }, body: '{}',
})).toMatchObject({ status: 404 });
expect(await dispatchHostApiRequest(context(productHost()), {
path: '/api/coding/conversations/conversation-a/prompt',
})).toMatchObject({ status: 404 });
});
});

View File

@@ -0,0 +1,142 @@
// @vitest-environment node
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import {
CodingProductHostError,
createCodingProductHost,
} from '../../electron/api/coding-product-services';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import {
createCodingProjectAgent,
createCodingProjectMetadata,
} from '../../electron/coding-projects/project-config';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
const roots: string[] = [];
const conversationId = '11111111-1111-4111-8111-111111111111';
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
async function configuredProject(): Promise<string> {
const root = await temporaryRoot('makelore-pi-products-');
await createCodingProjectMetadata(root, { now: '2026-08-23T00:00:00.000Z' });
await createCodingProjectAgent(root, {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
model: null,
modelResolution: 'required',
skillIds: ['agent-browser', 'grilling'],
responsibility: {
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
},
}, { now: '2026-08-23T00:00:00.000Z' });
await createCodingConversationStore(root, {
createId: () => conversationId,
now: () => '2026-08-23T00:00:00.000Z',
}).create({
agentId: 'builder', title: 'PI-105', model: null, modelResolution: 'required',
});
return root;
}
function productTools(root: string): PiProductTools {
return new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
}
describe('PI-105 product Host composition', () => {
it('projects managed skills and a stable command catalog without raw Pi fields', async () => {
const root = await configuredProject();
const tools = productTools(root);
const host = createCodingProductHost({
getActiveProject: async () => ({ id: 'project-a', path: root }),
productTools: tools,
listPiCommands: async () => ({
commands: [
{ name: 'custom', description: 'Custom Pi command', token: 'pi-secret' },
{ name: 'compact', description: 'Must not shadow Makelore' },
{ name: '../unsafe', description: 'Invalid command' },
],
apiKey: 'raw-provider-secret',
}),
});
const skills = await host.listSkills('builder');
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
'agent-browser', 'grilling',
]);
const commands = await host.listCommands(conversationId);
expect(commands.slice(0, 5).map(({ source }) => source)).toEqual([
'makelore', 'makelore', 'makelore', 'makelore', 'makelore',
]);
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
expect(commands.filter(({ name }) => name === 'compact')).toHaveLength(1);
const serialized = JSON.stringify({ skills, commands });
expect(serialized).not.toContain(root);
expect(serialized).not.toContain('pi-secret');
expect(serialized).not.toContain('raw-provider-secret');
expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/);
});
it('reads exact-run changes from the same PiProductTools tracker instance', async () => {
const root = await configuredProject();
await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8');
const tools = productTools(root);
const host = createCodingProductHost({
getActiveProject: async () => ({ id: 'project-a', path: root }),
productTools: tools,
});
await tools.beginRun({ conversationId, runId: 'run-a', projectPath: root });
await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8');
await tools.recordTouchedPaths(conversationId, 'run-a', ['notes.txt']);
expect(await host.getChanges(conversationId)).toMatchObject({
conversationId, runId: 'run-a', git: false,
files: [{ path: 'notes.txt', status: 'modified', preview: 'changed\n' }],
});
expect(JSON.stringify(await host.getChanges(conversationId))).not.toContain(root);
});
it('returns typed project, Agent, and Conversation errors', async () => {
const root = await configuredProject();
const tools = productTools(root);
const unavailable = createCodingProductHost({
getActiveProject: async () => null,
productTools: tools,
});
await expect(unavailable.fileStatus()).rejects.toMatchObject<CodingProductHostError>({
status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED',
});
const host = createCodingProductHost({
getActiveProject: async () => ({ id: 'project-a', path: root }),
productTools: tools,
});
await expect(host.listSkills('missing')).rejects.toMatchObject<CodingProductHostError>({
status: 404, code: 'CODING_AGENT_NOT_FOUND',
});
await expect(host.listCommands('22222222-2222-4222-8222-222222222222'))
.rejects.toMatchObject<CodingProductHostError>({
status: 404, code: 'CODING_CONVERSATION_NOT_FOUND',
});
});
});

View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('../../src/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
import {
findCodingFiles,
getCodingConversationChanges,
getCodingConversationCommands,
getCodingFileContent,
getCodingFileStatus,
getCodingSkills,
searchCodingText,
} from '../../src/lib/coding-product-tools';
describe('PI-105 Renderer coding product facade', () => {
beforeEach(() => {
hostApiFetchMock.mockReset().mockResolvedValue({});
});
it('uses only the vendor-neutral coding file and catalog routes', async () => {
await getCodingFileStatus();
await findCodingFiles('app file', 25);
await getCodingFileContent('src/app file.ts');
await searchCodingText('hello world');
await getCodingSkills('builder/one');
await getCodingConversationCommands('conversation/one');
await getCodingConversationChanges('conversation/one');
expect(hostApiFetchMock.mock.calls.map(([route]) => route)).toEqual([
'/api/coding/files/status',
'/api/coding/files/find?query=app+file&limit=25',
'/api/coding/files/content?path=src%2Fapp+file.ts',
'/api/coding/search?pattern=hello+world',
'/api/coding/skills?agentId=builder%2Fone',
'/api/coding/conversations/conversation%2Fone/commands',
'/api/coding/conversations/conversation%2Fone/changes',
]);
expect(JSON.stringify(hostApiFetchMock.mock.calls)).not.toContain('/api/opencode');
});
});

View File

@@ -0,0 +1,111 @@
// @vitest-environment node
import { execFile } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { afterEach, describe, expect, it } from 'vitest';
import { CodingProjectFileService } from '../../electron/coding-projects/project-files';
const exec = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
async function git(root: string, ...args: string[]): Promise<void> {
await exec('git', ['-C', root, ...args], { windowsHide: true });
}
async function initializeRepository(root: string): Promise<void> {
await git(root, 'init');
await git(root, 'config', 'user.email', 'pi-files@example.invalid');
await git(root, 'config', 'user.name', 'PI Files');
await mkdir(path.join(root, 'src'), { recursive: true });
await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "baseline";\n', 'utf8');
await writeFile(path.join(root, '.gitignore'), 'ignored.txt\n', 'utf8');
await git(root, 'add', '.');
await git(root, 'commit', '-m', 'baseline');
}
describe('PI-105 project file service', () => {
it('reports Git status and finds tracked or untracked files using project-relative DTOs', async () => {
const root = await temporaryRoot('makelore-pi-files-git-');
await initializeRepository(root);
await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "changed";\n', 'utf8');
await writeFile(path.join(root, 'src', 'app.test.ts'), 'test("changed", () => true);\n', 'utf8');
await writeFile(path.join(root, 'ignored.txt'), 'ignored\n', 'utf8');
const service = new CodingProjectFileService();
expect(await service.status(root)).toEqual([
expect.objectContaining({ path: 'src/app.ts', name: 'app.ts', status: 'modified' }),
expect.objectContaining({ path: 'src/app.test.ts', name: 'app.test.ts', status: 'untracked' }),
]);
const found = await service.find(root, 'app', 1);
expect(found).toHaveLength(1);
expect(found[0]).toMatchObject({ path: 'src/app.ts', name: 'app.ts', type: 'file' });
expect(JSON.stringify({ status: await service.status(root), found })).not.toContain(root);
expect((await service.find(root, 'ignored')).map(({ path: filePath }) => filePath)).toEqual([]);
});
it('returns bounded UTF-8 content and rejects absolute, traversal, and binary reads', async () => {
const root = await temporaryRoot('makelore-pi-files-content-');
await writeFile(path.join(root, 'notes.txt'), 'hello project\n', 'utf8');
await writeFile(path.join(root, 'large.txt'), 'x'.repeat((256 * 1024) + 100), 'utf8');
await writeFile(path.join(root, 'large-unicode.txt'), '你'.repeat(90_000), 'utf8');
await writeFile(path.join(root, 'binary.bin'), Buffer.from([1, 0, 2]));
const service = new CodingProjectFileService();
expect(await service.content(root, 'notes.txt')).toEqual({
path: 'notes.txt', content: 'hello project\n', truncated: false,
});
const large = await service.content(root, 'large.txt');
expect(Buffer.byteLength(large.content, 'utf8')).toBe(256 * 1024);
expect(large.truncated).toBe(true);
const largeUnicode = await service.content(root, 'large-unicode.txt');
expect(Buffer.byteLength(largeUnicode.content, 'utf8')).toBeLessThanOrEqual(256 * 1024);
expect(largeUnicode.content).toMatch(/^+$/u);
expect(largeUnicode.truncated).toBe(true);
await expect(service.content(root, '../secret.txt')).rejects.toThrow('escapes the active project');
await expect(service.content(root, path.resolve(root, 'notes.txt'))).rejects.toThrow('must be relative');
await expect(service.content(root, 'binary.bin')).rejects.toThrow('Binary project files');
});
it('searches literal text with bounded relative matches and falls back outside Git', async () => {
const root = await temporaryRoot('makelore-pi-files-search-');
await mkdir(path.join(root, 'docs'), { recursive: true });
await mkdir(path.join(root, 'node_modules', 'private-package'), { recursive: true });
await writeFile(path.join(root, 'docs', 'guide.md'), 'First line\nNeedle and needle again\n', 'utf8');
await writeFile(path.join(root, 'node_modules', 'private-package', 'secret.txt'), 'needle\n', 'utf8');
const service = new CodingProjectFileService({
async run() {
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
},
});
expect((await service.find(root, 'guide')).map(({ path: filePath }) => filePath)).toEqual([
'docs/guide.md',
]);
const matches = await service.search(root, 'needle');
expect(matches).toEqual([{
path: 'docs/guide.md',
name: 'guide.md',
lineNumber: 2,
lineText: 'Needle and needle again',
submatches: [
{ text: 'Needle', start: 0, end: 6 },
{ text: 'needle', start: 11, end: 17 },
],
}]);
expect(JSON.stringify(matches)).not.toContain(root);
expect(JSON.stringify(matches)).not.toContain('private-package');
});
});