343 lines
15 KiB
TypeScript
343 lines
15 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
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 = {
|
|
name: string;
|
|
execute?: (...arguments_: unknown[]) => Promise<unknown>;
|
|
};
|
|
|
|
const roots: string[] = [];
|
|
const hosts: PiManagedExtensionHost[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(hosts.splice(0).map((host) => host.close()));
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
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');
|
|
await writeFile(path.join(root, 'ASSET_PLAN.md'), [
|
|
'```json',
|
|
JSON.stringify({ assets: [{ id: 'hero', name: 'Hero', category: 'visual', status: 'candidate' }] }),
|
|
'```',
|
|
].join('\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,
|
|
};
|
|
},
|
|
async sendCdp() {
|
|
return { kind: 'inline', value: { data: Buffer.from('packaged-png').toString('base64') } };
|
|
},
|
|
} as unknown as AgentBrowserModule;
|
|
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'), {
|
|
createId: () => 'packaged-attachment-a',
|
|
});
|
|
const host = new PiManagedExtensionHost();
|
|
const productTools = new PiProductTools({
|
|
browser,
|
|
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({
|
|
content: [{ type: 'text', text: '1 changed path(s) recorded' }],
|
|
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);
|
|
const screenshotResult = await tools.get('agent_browser')?.execute?.(
|
|
'browser-screenshot-a',
|
|
{ action: 'send_cdp', method: 'Page.captureScreenshot', params: { format: 'png' } },
|
|
new AbortController().signal,
|
|
);
|
|
expect(screenshotResult).toMatchObject({
|
|
details: {
|
|
schema: 'agent-browser.v1', action: 'send_cdp',
|
|
attachmentId: 'packaged-attachment-a', mime: 'image/png',
|
|
},
|
|
});
|
|
expect(JSON.stringify(screenshotResult)).not.toContain(
|
|
Buffer.from('packaged-png').toString('base64'),
|
|
);
|
|
expect((await attachments.read('packaged-attachment-a')).data.toString()).toBe('packaged-png');
|
|
const gameBrowse = await tools.get('game_asset_browser')?.execute?.(
|
|
'game-browse-a', {}, new AbortController().signal,
|
|
);
|
|
expect(gameBrowse).toMatchObject({
|
|
details: { schema: 'game-assets.v1', candidateIds: ['hero'], status: 'pending' },
|
|
});
|
|
const gameReview = await tools.get('game_asset_review')?.execute?.(
|
|
'game-review-a', { candidateIds: ['hero'] }, new AbortController().signal,
|
|
);
|
|
expect(gameReview).toMatchObject({
|
|
details: { schema: 'game-assets.v1', candidateIds: ['hero'], status: 'pending' },
|
|
});
|
|
expect(JSON.stringify({ gameBrowse, gameReview })).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);
|
|
const host = new PiManagedExtensionHost();
|
|
const scheduler = new PiSubagentScheduler({
|
|
processBudget: new PiProcessBudget(8),
|
|
openChild: async (input) => ({
|
|
id: input.taskId,
|
|
async run() { return { summary: `done ${input.agentId}` }; },
|
|
async stop() {},
|
|
}),
|
|
});
|
|
host.configureSubagents({ scheduler });
|
|
hosts.push(host);
|
|
const extensionWorker = await host.registerWorker({
|
|
conversationId: 'conversation-a1', generation: 1, projectId: 'project-a', extensionsDir: root,
|
|
});
|
|
const waitingWorker = await host.registerWorker({
|
|
conversationId: 'conversation-a2', generation: 1, projectId: 'project-a', extensionsDir: root,
|
|
});
|
|
await Promise.all([
|
|
host.bindRun('conversation-a1', 1, 'run-a1'),
|
|
host.bindRun('conversation-a2', 1, 'run-a2'),
|
|
]);
|
|
|
|
const previousEnvironment = {
|
|
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, extensionWorker.env);
|
|
try {
|
|
const module = await import(/* @vite-ignore */ pathToFileURL(extensionWorker.extensionPath).href) as {
|
|
default(factory: {
|
|
registerTool(tool: ExtensionTool): void;
|
|
on(event: string, handler: ExtensionHandler): void;
|
|
}): void;
|
|
};
|
|
const handlers = new Map<string, ExtensionHandler>();
|
|
const tools = new Map<string, ExtensionTool>();
|
|
module.default({
|
|
registerTool: (tool) => tools.set(tool.name, tool),
|
|
on: (event, handler) => handlers.set(event, handler),
|
|
});
|
|
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?.(
|
|
'subagent-1',
|
|
{
|
|
mode: 'single',
|
|
tasks: [{ agentId: 'agent-a', task: 'Inspect', toolProfile: 'read-only' }],
|
|
},
|
|
new AbortController().signal,
|
|
(update: unknown) => updates.push(update),
|
|
);
|
|
expect(subagentResult).toMatchObject({
|
|
details: {
|
|
schema: 'subagent.v1', mode: 'single',
|
|
tasks: [{ agentId: 'agent-a', status: 'complete', summary: 'done agent-a' }],
|
|
},
|
|
});
|
|
expect(updates.length).toBeGreaterThan(0);
|
|
expect(updates.every((update) => (
|
|
(update as { details?: { schema?: string } }).details?.schema === 'subagent.v1'
|
|
))).toBe(true);
|
|
|
|
const statuses: Array<string | undefined> = [];
|
|
const context = {
|
|
signal: new AbortController().signal,
|
|
ui: { setStatus: (_key: string, text: string | undefined) => statuses.push(text) },
|
|
};
|
|
await handlers.get('tool_call')?.({ toolName: 'read', toolCallId: 'read-1' }, context);
|
|
await handlers.get('tool_call')?.({ toolName: 'write', toolCallId: 'write-1' }, context);
|
|
expect(statuses).toEqual(['等待项目写入', undefined]);
|
|
|
|
let waiterSettled = false;
|
|
const waiting = fetch(waitingWorker.env.MAKELORE_PI_BRIDGE_URL as string, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${waitingWorker.env.MAKELORE_PI_WORKER_TOKEN}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
action: 'lease.acquire', conversationId: 'conversation-a2', workerGeneration: 1,
|
|
runId: 'run-a2', resourceId: 'write-2',
|
|
}),
|
|
}).then((response) => {
|
|
waiterSettled = true;
|
|
return response;
|
|
});
|
|
await Promise.resolve();
|
|
expect(waiterSettled).toBe(false);
|
|
await handlers.get('tool_result')?.({ toolCallId: 'write-1' });
|
|
expect((await waiting).status).toBe(200);
|
|
} finally {
|
|
await scheduler.close();
|
|
if (previousEnvironment.bridge === undefined) delete process.env.MAKELORE_PI_BRIDGE_URL;
|
|
else process.env.MAKELORE_PI_BRIDGE_URL = previousEnvironment.bridge;
|
|
if (previousEnvironment.token === undefined) delete process.env.MAKELORE_PI_WORKER_TOKEN;
|
|
else process.env.MAKELORE_PI_WORKER_TOKEN = previousEnvironment.token;
|
|
if (previousEnvironment.context === undefined) delete process.env.MAKELORE_PI_CONTEXT_FILE;
|
|
else process.env.MAKELORE_PI_CONTEXT_FILE = previousEnvironment.context;
|
|
if (previousEnvironment.role === undefined) delete process.env.MAKELORE_PI_WORKER_ROLE;
|
|
else process.env.MAKELORE_PI_WORKER_ROLE = previousEnvironment.role;
|
|
}
|
|
});
|
|
|
|
it('does not expose parent-only tools from a child process', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-child-bundle-'));
|
|
roots.push(root);
|
|
await writeFile(path.join(root, 'child.txt'), 'before\n', 'utf8');
|
|
const host = new PiManagedExtensionHost();
|
|
const productTools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
});
|
|
host.configureProductTools(productTools);
|
|
hosts.push(host);
|
|
await host.registerWorker({
|
|
conversationId: 'conversation-child', generation: 1, projectId: 'project-a',
|
|
projectPath: root, extensionsDir: root,
|
|
});
|
|
await host.bindRun('conversation-child', 1, 'run-parent');
|
|
const child = await host.registerWorker({
|
|
conversationId: 'conversation-child', generation: 1, projectId: 'project-a',
|
|
projectPath: root, extensionsDir: root, role: 'child', runId: 'run-parent',
|
|
});
|
|
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, child.env);
|
|
try {
|
|
const module = await import(
|
|
/* @vite-ignore */ `${pathToFileURL(child.extensionPath).href}?child=${Date.now()}`
|
|
) as {
|
|
default(factory: {
|
|
registerTool(tool: ExtensionTool): void;
|
|
on(event: string, handler: ExtensionHandler): void;
|
|
}): void;
|
|
};
|
|
const tools: string[] = [];
|
|
const handlers = new Map<string, ExtensionHandler>();
|
|
module.default({
|
|
registerTool: (tool) => tools.push(tool.name),
|
|
on: (event, handler) => handlers.set(event, handler),
|
|
});
|
|
expect(tools).toEqual([]);
|
|
await writeFile(path.join(root, 'child.txt'), 'after\n', 'utf8');
|
|
await handlers.get('tool_call')?.({
|
|
toolName: 'write', toolCallId: 'child-write', input: { path: 'child.txt' },
|
|
}, {
|
|
signal: new AbortController().signal,
|
|
ui: { setStatus: () => undefined },
|
|
});
|
|
await handlers.get('tool_result')?.({ toolName: 'write', toolCallId: 'child-write' });
|
|
expect(productTools.getChanges('conversation-child')?.files).toEqual([
|
|
expect.objectContaining({ path: 'child.txt', preview: 'after\n' }),
|
|
]);
|
|
} 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;
|
|
}
|
|
}
|
|
});
|
|
});
|