feat: implement PI-090 product tools

This commit is contained in:
2026-08-23 15:14:50 +08:00
parent 77cdee7e73
commit 13ab383e53
32 changed files with 2035 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
@@ -8,6 +8,9 @@ import { afterEach, describe, expect, it } from 'vitest';
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
import { PiSubagentScheduler } from '../../electron/coding-runtime/pi/subagent';
import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool';
import type { AgentBrowserModule } from '../../electron/agent-browser';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown;
type ExtensionTool = {
@@ -24,6 +27,98 @@ afterEach(async () => {
});
describe('Makelore Pi extension bundle', () => {
it('executes versioned product tools through the authenticated real bundle', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-bundle-'));
roots.push(root);
await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8');
const browser = {
async getSnapshot() {
return {
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
state: 'attached', generation: 1, url: 'http://127.0.0.1:5173/', title: 'App',
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
};
},
} as unknown as AgentBrowserModule;
const host = new PiManagedExtensionHost();
const productTools = new PiProductTools({
browser,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
});
host.configureProductTools(productTools);
hosts.push(host);
const worker = await host.registerWorker({
conversationId: 'conversation-tools', generation: 1, projectId: 'project-a',
projectPath: root, skillIds: ['agent-browser'], extensionsDir: root,
});
await host.bindRun('conversation-tools', 1, 'run-tools');
const previous = {
bridge: process.env.MAKELORE_PI_BRIDGE_URL,
token: process.env.MAKELORE_PI_WORKER_TOKEN,
context: process.env.MAKELORE_PI_CONTEXT_FILE,
role: process.env.MAKELORE_PI_WORKER_ROLE,
};
Object.assign(process.env, worker.env);
try {
const module = await import(
/* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?tools=${Date.now()}`
) as {
default(factory: {
registerTool(tool: ExtensionTool): void;
on(event: string, handler: ExtensionHandler): void;
}): void;
};
const tools = new Map<string, ExtensionTool>();
const handlers = new Map<string, ExtensionHandler>();
module.default({
registerTool: (tool) => tools.set(tool.name, tool),
on: (event, handler) => handlers.set(event, handler),
});
await expect(tools.get('task_state')?.execute?.(
'task-state-a',
{ tasks: [{ id: 'one', title: 'Inspect', status: 'complete' }] },
new AbortController().signal,
)).resolves.toMatchObject({ details: { schema: 'task-state.v1' } });
await expect(tools.get('changed_file')?.execute?.(
'changed-a', { paths: ['notes.txt'] }, new AbortController().signal,
)).resolves.toMatchObject({ details: { schema: 'changed-file.v1', paths: ['notes.txt'] } });
await expect(tools.get('runtime_context')?.execute?.(
'context-a', {}, new AbortController().signal,
)).resolves.toMatchObject({
details: {
schema: 'runtime-context.v1',
skills: expect.arrayContaining([expect.objectContaining({ id: 'agent-browser', selected: true })]),
},
});
const browserResult = await tools.get('agent_browser')?.execute?.(
'browser-a', { action: 'status' }, new AbortController().signal,
);
expect(browserResult).toMatchObject({ details: { schema: 'agent-browser.v1', action: 'status' } });
expect(JSON.stringify(browserResult)).not.toContain(root);
await writeFile(path.join(root, 'notes.txt'), 'changed by write tool\n', 'utf8');
await handlers.get('tool_call')?.({
toolName: 'write', toolCallId: 'write-a', input: { path: path.join(root, 'notes.txt') },
}, {
signal: new AbortController().signal,
ui: { setStatus: () => undefined },
});
await handlers.get('tool_result')?.({ toolName: 'write', toolCallId: 'write-a' });
expect(productTools.getChanges('conversation-tools')?.files).toEqual([
expect.objectContaining({ path: 'notes.txt' }),
]);
} finally {
for (const [key, value] of Object.entries(previous)) {
const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL'
: key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN'
: key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE'
: 'MAKELORE_PI_WORKER_ROLE';
if (value === undefined) delete process.env[environmentKey];
else process.env[environmentKey] = value;
}
}
});
it('loads the real bundle and releases its project lease on tool_result', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-bundle-'));
roots.push(root);
@@ -69,7 +164,10 @@ describe('Makelore Pi extension bundle', () => {
registerTool: (tool) => tools.set(tool.name, tool),
on: (event, handler) => handlers.set(event, handler),
});
expect([...tools.keys()]).toEqual(['ask_user', 'subagent']);
expect([...tools.keys()]).toEqual([
'ask_user', 'subagent', 'agent_browser', 'game_asset_browser',
'game_asset_review', 'task_state', 'changed_file', 'runtime_context',
]);
const updates: unknown[] = [];
const subagentResult = await tools.get('subagent')?.execute?.(