Files
makelore/tests/unit/pi-extension-bundle.test.ts

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;
}
}
});
});