758 lines
32 KiB
TypeScript
758 lines
32 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { mkdtemp, readFile, 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, vi } 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';
|
|
import {
|
|
DevicePackageError,
|
|
type DevicePackageManager,
|
|
} from '../../electron/coding-packages/device-package-manager';
|
|
import {
|
|
DEVICE_PACKAGE_TOOL_DEFINITIONS,
|
|
DevicePackageTools,
|
|
} from '../../electron/coding-packages/device-package-tools';
|
|
import { CodingCapabilityRegistryImpl } from '../../electron/coding-plugins/registry';
|
|
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
|
|
import {
|
|
DATA_SERVICE_PLUGIN_DEFINITION,
|
|
type CodingPluginToolDefinition,
|
|
} from '../../shared/coding-plugins';
|
|
|
|
type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown;
|
|
type ExtensionTool = {
|
|
name: string;
|
|
parameters?: Record<string, unknown>;
|
|
executionMode?: 'sequential';
|
|
execute?: (...arguments_: unknown[]) => Promise<unknown>;
|
|
};
|
|
|
|
async function post(
|
|
registration: Awaited<ReturnType<PiManagedExtensionHost['registerWorker']>>,
|
|
body: Record<string, unknown>,
|
|
): Promise<Response> {
|
|
return await fetch(registration.env.MAKELORE_PI_BRIDGE_URL as string, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${registration.env.MAKELORE_PI_WORKER_TOKEN}`,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
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('preserves a closed Device Package failure through the real worker bridge', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-device-package-error-'));
|
|
roots.push(root);
|
|
const manager = {
|
|
async prepare() {
|
|
throw new DevicePackageError(
|
|
'local_package_dependency_failed',
|
|
'Bundled Pi package manager is unavailable',
|
|
);
|
|
},
|
|
} as unknown as DevicePackageManager;
|
|
const host = new PiManagedExtensionHost();
|
|
host.configureProductTools(new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
devicePackageTools: new DevicePackageTools(manager),
|
|
}));
|
|
hosts.push(host);
|
|
const prepareTool = DEVICE_PACKAGE_TOOL_DEFINITIONS.find(
|
|
({ name }) => name === 'local_package_prepare',
|
|
);
|
|
if (!prepareTool) throw new Error('Device Package prepare definition missing');
|
|
const worker = await host.registerWorker({
|
|
conversationId: 'device-package-conversation',
|
|
generation: 1,
|
|
projectId: 'project-a',
|
|
projectPath: root,
|
|
extensionsDir: root,
|
|
tools: [prepareTool],
|
|
});
|
|
await host.bindRun('device-package-conversation', 1, 'device-package-run');
|
|
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}?device-package=${Date.now()}`
|
|
) as {
|
|
default(factory: {
|
|
registerTool(tool: ExtensionTool): void;
|
|
on(event: string, handler: ExtensionHandler): void;
|
|
}): void | Promise<void>;
|
|
};
|
|
const tools = new Map<string, ExtensionTool>();
|
|
await module.default({
|
|
registerTool: (tool) => tools.set(tool.name, tool),
|
|
on: () => undefined,
|
|
});
|
|
|
|
await expect(tools.get('local_package_prepare')?.execute?.(
|
|
'prepare-a',
|
|
{ source: 'npm:pi-web-search' },
|
|
new AbortController().signal,
|
|
)).rejects.toThrow(
|
|
'local_package_dependency_failed: Bundled Pi package manager is unavailable',
|
|
);
|
|
} 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('hydrates persisted Pi identity through reconnect and event replay into the capability registry', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-persisted-identity-'));
|
|
roots.push(root);
|
|
const policy: PluginPolicyClientState = {
|
|
status: 'current', revision: 23, lastVerifiedAt: 1,
|
|
catalog: {
|
|
schema_version: 1, catalog_version: 'catalog-23', pricing_version: null,
|
|
plugins: [{
|
|
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
supported_contract_versions: [DATA_SERVICE_PLUGIN_DEFINITION.contractVersion],
|
|
status: 'active',
|
|
capabilities: [{
|
|
capability_id: 'data-service.control',
|
|
operations: [{
|
|
operation: 'inspect',
|
|
billing: { mode: 'included', entitlement_scope: null, notice: 'Included' },
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
};
|
|
const invoke = vi.fn(async () => ({
|
|
success: true as const, status: 200, code: null, error: null, retryable: false as const,
|
|
payload_schema: 'data-service.v1', data: { instance_id: 'instance-a' },
|
|
}));
|
|
const capabilityRegistry = new CodingCapabilityRegistryImpl({
|
|
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
|
|
getEnabledPluginIds: async () => [DATA_SERVICE_PLUGIN_DEFINITION.id],
|
|
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
|
|
adapters: [{
|
|
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
async inspect() { return { status: 'ready' }; },
|
|
invoke,
|
|
}],
|
|
});
|
|
const host = new PiManagedExtensionHost();
|
|
host.configureProductTools(new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
capabilityRegistry,
|
|
}));
|
|
hosts.push(host);
|
|
const inspectTool = DATA_SERVICE_PLUGIN_DEFINITION.tools.find(
|
|
({ name }) => name === 'data_service_inspect',
|
|
);
|
|
if (!inspectTool) throw new Error('inspect definition missing');
|
|
const worker = await host.registerWorker({
|
|
conversationId: 'persisted-conversation', generation: 1, projectId: 'project-a',
|
|
projectPath: root, extensionsDir: root,
|
|
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
|
catalogRevision: policy.revision,
|
|
tools: [inspectTool],
|
|
});
|
|
await host.bindRun('persisted-conversation', 1, 'persisted-run');
|
|
const persistedContext = JSON.parse(await readFile(
|
|
worker.env.MAKELORE_PI_CONTEXT_FILE as string,
|
|
'utf8',
|
|
)) as { runId?: string };
|
|
expect(persistedContext.runId).toBe('persisted-run');
|
|
|
|
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 executeAfterHydration = async (connection: string) => {
|
|
const module = await import(
|
|
/* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?connection=${connection}`
|
|
) as {
|
|
default(factory: {
|
|
registerTool(tool: ExtensionTool): void;
|
|
on(event: string, handler: ExtensionHandler): void;
|
|
}): void | Promise<void>;
|
|
};
|
|
const tools = new Map<string, ExtensionTool>();
|
|
await module.default({
|
|
registerTool: (tool) => tools.set(tool.name, tool),
|
|
on: () => undefined,
|
|
});
|
|
return await tools.get('data_service_inspect')?.execute?.(
|
|
'persisted-resource', {}, new AbortController().signal,
|
|
);
|
|
};
|
|
|
|
const first = await executeAfterHydration('initial');
|
|
const replay = await executeAfterHydration('reconnect');
|
|
expect(first).toMatchObject({
|
|
details: {
|
|
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
|
request_id: 'pi:persisted-run:persisted-resource',
|
|
},
|
|
});
|
|
expect(replay).toMatchObject({
|
|
details: { request_id: 'pi:persisted-run:persisted-resource' },
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
} 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('materializes only the frozen plugin declarations and lease metadata', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-dynamic-bundle-'));
|
|
roots.push(root);
|
|
const host = new PiManagedExtensionHost();
|
|
hosts.push(host);
|
|
const pluginTool: CodingPluginToolDefinition = {
|
|
name: 'data_service_inspect',
|
|
label: 'Data Service inspect',
|
|
description: 'Inspect the active project Data Service instance.',
|
|
capabilityId: 'data-service.control',
|
|
operation: 'inspect',
|
|
roles: ['parent'],
|
|
mutation: 'read',
|
|
projectWriteLease: true,
|
|
permissions: ['project.data.read'],
|
|
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
};
|
|
const registration = await host.registerWorker({
|
|
conversationId: 'dynamic-conversation',
|
|
generation: 1,
|
|
projectId: 'dynamic-project',
|
|
projectPath: root,
|
|
extensionsDir: root,
|
|
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
|
catalogRevision: 19,
|
|
tools: [pluginTool],
|
|
});
|
|
await host.bindRun('dynamic-conversation', 1, 'dynamic-run');
|
|
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, registration.env);
|
|
try {
|
|
const module = await import(
|
|
/* @vite-ignore */ `${pathToFileURL(registration.extensionPath).href}?dynamic=${Date.now()}`
|
|
) as {
|
|
default(factory: {
|
|
registerTool(tool: ExtensionTool): void;
|
|
on(event: string, handler: ExtensionHandler): void;
|
|
}): void | Promise<void>;
|
|
};
|
|
const tools = new Map<string, ExtensionTool>();
|
|
await module.default({
|
|
registerTool: (tool) => tools.set(tool.name, tool),
|
|
on: () => undefined,
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(tools.has('data_service_inspect')).toBe(true);
|
|
expect(tools.has('data_service_put_document')).toBe(false);
|
|
expect(tools.get('data_service_inspect')?.parameters).toEqual(pluginTool.inputSchema);
|
|
expect(tools.get('data_service_inspect')?.executionMode).toBe('sequential');
|
|
const context = JSON.parse(await readFile(registration.env.MAKELORE_PI_CONTEXT_FILE as string, 'utf8'));
|
|
expect(context).toMatchObject({
|
|
catalogRevision: 19,
|
|
allowedToolNames: ['data_service_inspect'],
|
|
projectWriteLeaseToolNames: ['data_service_inspect'],
|
|
});
|
|
const denied = await post(registration, {
|
|
action: 'product.invoke', conversationId: 'dynamic-conversation',
|
|
workerGeneration: 1, runId: 'dynamic-run', resourceId: 'denied-tool',
|
|
toolName: 'data_service_put_document', input: {},
|
|
});
|
|
expect(denied.status).toBe(403);
|
|
} 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('streams job-tool progress through the authenticated product bridge', async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-stream-'));
|
|
roots.push(root);
|
|
const host = new PiManagedExtensionHost();
|
|
hosts.push(host);
|
|
const details = (phase: string) => ({
|
|
schema: 'makelore-capability.v1' as const,
|
|
plugin_id: 'makelore.game-resource',
|
|
plugin_version: '1.0.0',
|
|
capability_id: 'game-resource.generate',
|
|
operation: 'generate',
|
|
request_id: 'pi:stream-run:stream-tool',
|
|
success: true,
|
|
status: phase === 'saved' ? 200 : 202,
|
|
code: null,
|
|
error: null,
|
|
retryable: false,
|
|
billing: {
|
|
mode: 'platform_metered' as const,
|
|
status: phase === 'saved' ? 'settled' as const : 'dispatched' as const,
|
|
reserved_points: '2.00',
|
|
...(phase === 'saved' ? { actual_points: '2.00' } : {}),
|
|
usage_amount: 1,
|
|
unit: 'generation',
|
|
},
|
|
payload_schema: 'game-resource.v1',
|
|
data: { phase, executionId: 'execution-a' },
|
|
});
|
|
const invoke = vi.fn(async (input: {
|
|
onUpdate?: (result: { content: []; details: ReturnType<typeof details> }) => void;
|
|
}) => {
|
|
input.onUpdate?.({ content: [], details: details('generating') });
|
|
input.onUpdate?.({ content: [], details: details('saving') });
|
|
return { content: [], details: details('saved') };
|
|
});
|
|
host.configureProductTools(new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistryImpl,
|
|
}));
|
|
const jobTool: CodingPluginToolDefinition = {
|
|
name: 'game_resource_generate',
|
|
label: 'Generate game resource',
|
|
description: 'Generate and save a game resource.',
|
|
capabilityId: 'game-resource.generate',
|
|
operation: 'generate',
|
|
roles: ['parent'],
|
|
mutation: 'write',
|
|
projectWriteLease: false,
|
|
permissions: ['hosted.game-resource.generate'],
|
|
executionMode: 'job',
|
|
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
};
|
|
const registration = await host.registerWorker({
|
|
conversationId: 'stream-conversation', generation: 1, projectId: 'stream-project',
|
|
projectPath: root, extensionsDir: root, tools: [jobTool],
|
|
});
|
|
await host.bindRun('stream-conversation', 1, 'stream-run');
|
|
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, registration.env);
|
|
try {
|
|
const module = await import(
|
|
/* @vite-ignore */ `${pathToFileURL(registration.extensionPath).href}?stream=${Date.now()}`
|
|
) as {
|
|
default(factory: {
|
|
registerTool(tool: ExtensionTool): void;
|
|
on(event: string, handler: ExtensionHandler): void;
|
|
}): void | Promise<void>;
|
|
};
|
|
const tools = new Map<string, ExtensionTool>();
|
|
await module.default({
|
|
registerTool: (tool) => tools.set(tool.name, tool),
|
|
on: () => undefined,
|
|
});
|
|
const updates: unknown[] = [];
|
|
const result = await tools.get('game_resource_generate')?.execute?.(
|
|
'stream-tool',
|
|
{},
|
|
new AbortController().signal,
|
|
(update: unknown) => updates.push(update),
|
|
);
|
|
|
|
expect(updates).toEqual([
|
|
{ content: [], details: details('generating') },
|
|
{ content: [], details: details('saving') },
|
|
]);
|
|
expect(result).toEqual({ content: [], details: details('saved') });
|
|
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
|
|
toolName: 'game_resource_generate',
|
|
onUpdate: expect.any(Function),
|
|
}));
|
|
} 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('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,
|
|
skillEntries: [],
|
|
catalogRevision: 3,
|
|
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>();
|
|
await 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: [],
|
|
},
|
|
});
|
|
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');
|
|
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,
|
|
tools: [...DATA_SERVICE_PLUGIN_DEFINITION.tools],
|
|
catalogRevision: 4,
|
|
});
|
|
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>();
|
|
await 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', 'task_state', 'changed_file', 'runtime_context',
|
|
'data_service_configure', 'data_service_inspect', 'data_service_list_projects',
|
|
'data_service_get_document', 'data_service_list_documents', 'data_service_put_document',
|
|
'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset',
|
|
'data_service_remove_project',
|
|
]);
|
|
const dataServiceTools = [
|
|
'data_service_configure', 'data_service_inspect', 'data_service_list_projects',
|
|
'data_service_get_document', 'data_service_list_documents', 'data_service_put_document',
|
|
'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset',
|
|
'data_service_remove_project',
|
|
];
|
|
for (const name of dataServiceTools) {
|
|
const tool = tools.get(name);
|
|
expect(tool?.parameters).toEqual(
|
|
DATA_SERVICE_PLUGIN_DEFINITION.tools.find(({ name: candidate }) => candidate === name)?.inputSchema,
|
|
);
|
|
}
|
|
|
|
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>();
|
|
await module.default({
|
|
registerTool: (tool) => tools.push(tool.name),
|
|
on: (event, handler) => handlers.set(event, handler),
|
|
});
|
|
expect(tools).toEqual([]);
|
|
const forgedParentTool = await post(child, {
|
|
action: 'product.invoke', conversationId: 'conversation-child', workerGeneration: 1,
|
|
runId: 'run-parent', resourceId: 'forged-data-tool',
|
|
toolName: 'data_service_inspect', input: {},
|
|
});
|
|
expect(forgedParentTool.status).toBe(403);
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
});
|