658 lines
29 KiB
TypeScript
658 lines
29 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { execFile } from 'node:child_process';
|
|
import { mkdtemp, mkdir, rm, utimes, 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, vi } from 'vitest';
|
|
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
|
import { AgentBrowserFault } from '../../electron/agent-browser/fault';
|
|
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
|
import {
|
|
ConversationChangeTracker,
|
|
type ConversationGitAdapter,
|
|
} from '../../electron/coding-projects/conversation-change-tracker';
|
|
import {
|
|
buildProductCodingCommandCatalog,
|
|
listProductCodingSkills,
|
|
} from '../../electron/coding-projects/skill-registry';
|
|
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
|
import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol';
|
|
import type { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry';
|
|
import type { ModelToolRegistryPort } from '../../electron/coding-runtime/pi/model-tools/model-tool-registry';
|
|
import type { DevicePackageTools } from '../../electron/coding-packages/device-package-tools';
|
|
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
|
|
|
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-tools@example.invalid');
|
|
await git(root, 'config', 'user.name', 'PI Tools');
|
|
await writeFile(path.join(root, 'existing.txt'), 'baseline\n', 'utf8');
|
|
await mkdir(path.join(root, 'src'), { recursive: true });
|
|
await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 1;\n', 'utf8');
|
|
await git(root, 'add', '.');
|
|
await git(root, 'commit', '-m', 'baseline');
|
|
}
|
|
|
|
describe('PI-090 product tools', () => {
|
|
it('tracks touched paths precisely and performs a project refresh after bash', async () => {
|
|
const root = await temporaryRoot('makelore-pi-changes-');
|
|
await initializeRepository(root);
|
|
await writeFile(path.join(root, 'existing.txt'), 'pre-existing dirty\n', 'utf8');
|
|
await writeFile(path.join(root, 'pre-existing-untracked.txt'), 'remove during run\n', 'utf8');
|
|
const tracker = new ConversationChangeTracker();
|
|
const started = await tracker.beginRun({
|
|
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
|
|
});
|
|
expect(started.git).toBe(true);
|
|
expect(started.baselineHead).toMatch(/^[a-f0-9]{40}$/);
|
|
|
|
await writeFile(path.join(root, 'src', 'app.ts'), 'export const value = 2;\n', 'utf8');
|
|
const precise = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['src/app.ts']);
|
|
expect(precise.files.map((file) => file.path)).toEqual(['src/app.ts']);
|
|
expect(precise.files[0]).toMatchObject({ status: 'modified' });
|
|
expect(precise.files[0]?.diff).toContain('value = 2');
|
|
|
|
await writeFile(path.join(root, 'bash-created.txt'), 'created by command\n', 'utf8');
|
|
await rm(path.join(root, 'pre-existing-untracked.txt'));
|
|
await tracker.markProjectRefresh('conversation-a', 'run-a');
|
|
const settled = await tracker.settleRun('conversation-a', 'run-a');
|
|
expect(settled?.files.map((file) => file.path)).toEqual([
|
|
'bash-created.txt', 'pre-existing-untracked.txt', 'src/app.ts',
|
|
]);
|
|
expect(settled?.files.find((file) => file.path === 'bash-created.txt')).toMatchObject({
|
|
status: 'untracked', preview: 'created by command\n',
|
|
});
|
|
expect(settled?.files.find((file) => file.path === 'pre-existing-untracked.txt')).toMatchObject({
|
|
status: 'deleted',
|
|
});
|
|
expect(JSON.stringify(settled)).not.toContain(root);
|
|
expect(settled?.files.some((file) => file.path === 'existing.txt')).toBe(false);
|
|
});
|
|
|
|
it('bounds untracked reads and detects equal-length changes beyond the preview', async () => {
|
|
const root = await temporaryRoot('makelore-pi-bounded-preview-');
|
|
await initializeRepository(root);
|
|
const target = path.join(root, 'large.txt');
|
|
await writeFile(target, 'a'.repeat(9 * 1024), 'utf8');
|
|
const tracker = new ConversationChangeTracker();
|
|
await tracker.beginRun({
|
|
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
|
|
});
|
|
await writeFile(target, `${'a'.repeat((9 * 1024) - 1)}b`, 'utf8');
|
|
const changedAt = new Date(Date.now() + 5_000);
|
|
await utimes(target, changedAt, changedAt);
|
|
const snapshot = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['large.txt']);
|
|
const file = snapshot?.files.find(({ path: filePath }) => filePath === 'large.txt');
|
|
expect(file).toMatchObject({ status: 'untracked', truncated: true });
|
|
expect(Buffer.byteLength(file?.preview ?? '', 'utf8')).toBeLessThanOrEqual(8 * 1024);
|
|
expect(JSON.stringify(snapshot)).not.toContain(root);
|
|
});
|
|
|
|
it('captures a dirty baseline without per-file Git diffs', async () => {
|
|
const root = await temporaryRoot('makelore-pi-dirty-baseline-');
|
|
const calls: string[][] = [];
|
|
const status = Array.from({ length: 100 }, (_, index) => (
|
|
`1 .M N... 100644 100644 100644 abc abc dirty-${index}.txt\0`
|
|
)).join('');
|
|
const adapter: ConversationGitAdapter = {
|
|
async run(_projectPath, args) {
|
|
calls.push([...args]);
|
|
if (args[0] === 'rev-parse' && args[1] === '--is-inside-work-tree') {
|
|
return { code: 0, stdout: 'true\n' };
|
|
}
|
|
if (args[0] === 'rev-parse') return { code: 0, stdout: `${'a'.repeat(40)}\n` };
|
|
if (args[0] === 'status') return { code: 0, stdout: status };
|
|
throw new Error(`Unexpected Git call: ${args.join(' ')}`);
|
|
},
|
|
};
|
|
const tracker = new ConversationChangeTracker(adapter);
|
|
expect((await tracker.beginRun({
|
|
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
|
|
})).git).toBe(true);
|
|
expect(calls).toHaveLength(3);
|
|
expect(calls.some(([command]) => command === 'diff')).toBe(false);
|
|
});
|
|
|
|
it('degrades to no-Git tracking when the Git executable is unavailable', async () => {
|
|
const root = await temporaryRoot('makelore-pi-git-unavailable-');
|
|
await writeFile(path.join(root, 'notes.txt'), 'local notes\n', 'utf8');
|
|
const tracker = new ConversationChangeTracker({
|
|
async run() {
|
|
throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
|
|
},
|
|
});
|
|
expect((await tracker.beginRun({
|
|
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
|
|
})).git).toBe(false);
|
|
expect((await tracker.recordTouchedPaths(
|
|
'conversation-a', 'run-a', ['notes.txt'],
|
|
)).files).toEqual([expect.objectContaining({ path: 'notes.txt', preview: 'local notes\n' })]);
|
|
});
|
|
|
|
it('keeps the changed path when a candidate diff is unavailable or oversized', async () => {
|
|
const root = await temporaryRoot('makelore-pi-diff-unavailable-');
|
|
await writeFile(path.join(root, 'tracked.txt'), 'changed\n', 'utf8');
|
|
let statusReads = 0;
|
|
const tracker = new ConversationChangeTracker({
|
|
async run(_projectPath, args) {
|
|
if (args[0] === 'rev-parse' && args[1] === '--is-inside-work-tree') {
|
|
return { code: 0, stdout: 'true\n' };
|
|
}
|
|
if (args[0] === 'rev-parse') return { code: 0, stdout: `${'a'.repeat(40)}\n` };
|
|
if (args[0] === 'status') {
|
|
statusReads += 1;
|
|
return {
|
|
code: 0,
|
|
stdout: statusReads === 1
|
|
? ''
|
|
: '1 .M N... 100644 100644 100644 abc abc tracked.txt\0',
|
|
};
|
|
}
|
|
if (args[0] === 'diff') throw new Error('Git output is too large');
|
|
throw new Error(`Unexpected Git call: ${args.join(' ')}`);
|
|
},
|
|
});
|
|
await tracker.beginRun({
|
|
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
|
|
});
|
|
const snapshot = await tracker.recordTouchedPaths(
|
|
'conversation-a', 'run-a', ['tracked.txt'],
|
|
);
|
|
expect(snapshot.files).toEqual([{
|
|
path: 'tracked.txt', status: 'modified',
|
|
}]);
|
|
});
|
|
|
|
it('supports no-git projects and rejects paths outside the project', async () => {
|
|
const root = await temporaryRoot('makelore-pi-no-git-');
|
|
await writeFile(path.join(root, 'notes.txt'), 'local notes\n', 'utf8');
|
|
const tracker = new ConversationChangeTracker();
|
|
expect((await tracker.beginRun({
|
|
conversationId: 'conversation-a', runId: 'run-a', projectPath: root,
|
|
})).git).toBe(false);
|
|
const snapshot = await tracker.recordTouchedPaths('conversation-a', 'run-a', ['notes.txt']);
|
|
expect(snapshot.files).toEqual([expect.objectContaining({
|
|
path: 'notes.txt', status: 'modified', preview: 'local notes\n',
|
|
})]);
|
|
await expect(tracker.recordTouchedPaths(
|
|
'conversation-a', 'run-a', ['../secret.txt'],
|
|
)).rejects.toThrow('escapes the project');
|
|
await expect(tracker.recordTouchedPaths(
|
|
'conversation-a', 'run-a', [path.resolve(root, 'notes.txt')],
|
|
)).rejects.toThrow('project-relative');
|
|
});
|
|
|
|
it('projects only bundled selected skills and safe command metadata', async () => {
|
|
const skills = await listProductCodingSkills(
|
|
path.resolve('resources/coding-skills'),
|
|
['agent-browser', 'grilling'],
|
|
);
|
|
expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([
|
|
'agent-browser', 'grilling',
|
|
]);
|
|
expect(skills[0]?.location).toBe('resources/coding-skills/agent-browser');
|
|
expect(JSON.stringify(skills)).not.toContain(path.resolve('resources/coding-skills'));
|
|
const commands = buildProductCodingCommandCatalog(skills, [
|
|
{ name: 'custom', description: 'Custom Pi command' },
|
|
{ name: 'compact', description: 'Must not shadow Makelore' },
|
|
]);
|
|
expect(commands).toContainEqual(expect.objectContaining({ name: 'compact', source: 'makelore' }));
|
|
expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' }));
|
|
expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' }));
|
|
expect(commands.some(({ name }) => name === 'planning-with-files')).toBe(false);
|
|
await expect(listProductCodingSkills(
|
|
path.resolve('resources/coding-skills'), ['not-installed'],
|
|
)).rejects.toThrow('Unknown bundled coding skill');
|
|
});
|
|
|
|
it('hides unassigned disabled plugin Skills and retains assigned ones as ineffective', async () => {
|
|
const source = [{
|
|
id: 'data-service',
|
|
directory: path.resolve('resources/coding-plugins/data-service/skills/data-service'),
|
|
available: false,
|
|
}];
|
|
const unassigned = await listProductCodingSkills(
|
|
path.resolve('resources/coding-skills'), [], source,
|
|
);
|
|
expect(unassigned.some(({ id }) => id === 'data-service')).toBe(false);
|
|
|
|
const retained = await listProductCodingSkills(
|
|
path.resolve('resources/coding-skills'), ['data-service'], source,
|
|
);
|
|
expect(retained.find(({ id }) => id === 'data-service')).toMatchObject({
|
|
selected: true, available: false, effective: false,
|
|
});
|
|
|
|
const reenabled = await listProductCodingSkills(
|
|
path.resolve('resources/coding-skills'), ['data-service'], [{ ...source[0], available: true }],
|
|
);
|
|
expect(reenabled.find(({ id }) => id === 'data-service')).toMatchObject({
|
|
selected: true, available: true, effective: true,
|
|
});
|
|
});
|
|
|
|
it('preserves an uninstalled unknown assignment in project config without blocking projections', async () => {
|
|
const root = await temporaryRoot('makelore-pi-removed-skill-');
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
getPluginSkillSources: async () => [],
|
|
});
|
|
|
|
await expect(tools.listSkills(['removed-plugin-skill'])).resolves.not.toContainEqual(
|
|
expect.objectContaining({ id: 'removed-plugin-skill' }),
|
|
);
|
|
});
|
|
|
|
it('projects effective plugin Skills in the worker runtime context', async () => {
|
|
const root = await temporaryRoot('makelore-pi-runtime-context-');
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
pluginSkillSources: [{
|
|
id: 'data-service',
|
|
pluginId: 'makelore.data-service',
|
|
directory: path.resolve('resources/coding-plugins/data-service/skills/data-service'),
|
|
}],
|
|
});
|
|
const result = await tools.execute('runtime_context', {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
|
projectId: 'project-a', projectPath: root, skillIds: ['data-service'],
|
|
}, {});
|
|
|
|
expect(result.details.schema).toBe('runtime-context.v1');
|
|
if (result.details.schema !== 'runtime-context.v1') throw new Error('runtime context missing');
|
|
expect(result.details.skills.find(({ id }) => id === 'data-service')).toMatchObject({
|
|
id: 'data-service', selected: true, available: true, effective: true,
|
|
});
|
|
expect(result.details.commands).toContainEqual(expect.objectContaining({ skillId: 'data-service' }));
|
|
});
|
|
|
|
it('accepts only safe versioned product detail projections', () => {
|
|
expect(productToolDetails({
|
|
schema: 'changed-file.v1', paths: ['src/app.ts', '.makelore/project.json'],
|
|
})).toEqual({
|
|
schema: 'changed-file.v1', paths: ['src/app.ts', '.makelore/project.json'],
|
|
});
|
|
expect(productToolDetails({
|
|
schema: 'changed-file.v1', paths: ['C:\\private\\secret.txt'],
|
|
})).toBeNull();
|
|
expect(productToolDetails({
|
|
schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'attachment-a',
|
|
})).toBeNull();
|
|
expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull();
|
|
expect(productToolDetails({
|
|
schema: 'makelore-capability.v1',
|
|
plugin_id: 'makelore.data-service',
|
|
plugin_version: '1.0.0',
|
|
capability_id: 'data-service.control',
|
|
operation: 'inspect',
|
|
request_id: 'pi:run-a:resource-a',
|
|
success: true,
|
|
status: 200,
|
|
code: null,
|
|
error: null,
|
|
retryable: false,
|
|
billing: { mode: 'included', status: 'included' },
|
|
payload_schema: 'data-service.v1',
|
|
data: { instance_id: 'instance-a' },
|
|
owner: 'must-be-dropped',
|
|
})).toBeNull();
|
|
expect(productToolDetails({
|
|
schema: 'makelore-capability.v1',
|
|
plugin_id: 'makelore.data-service',
|
|
plugin_version: '1.0.0',
|
|
capability_id: 'data-service.control',
|
|
operation: 'inspect',
|
|
request_id: 'pi:run-a:resource-a',
|
|
success: true,
|
|
status: 200,
|
|
code: null,
|
|
error: null,
|
|
retryable: false,
|
|
billing: { mode: 'included', status: 'included' },
|
|
payload_schema: 'data-service.v1',
|
|
data: null,
|
|
})).toMatchObject({ schema: 'makelore-capability.v1', operation: 'inspect' });
|
|
});
|
|
|
|
it('stores browser screenshots as attachment ids and never returns base64', async () => {
|
|
const root = await temporaryRoot('makelore-pi-browser-tool-');
|
|
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'), {
|
|
createId: () => 'attachment-a',
|
|
});
|
|
const calls: unknown[] = [];
|
|
const browser = {
|
|
async sendCdp(input: unknown) {
|
|
calls.push(input);
|
|
return { kind: 'inline', value: { data: Buffer.from('png-data').toString('base64') } };
|
|
},
|
|
} as unknown as AgentBrowserModule;
|
|
const tools = new PiProductTools({
|
|
browser,
|
|
attachments,
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
});
|
|
const result = await tools.execute('agent_browser', {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
|
projectId: 'project-a', projectPath: root, skillIds: ['agent-browser'],
|
|
}, { action: 'send_cdp', method: 'Page.captureScreenshot', params: { format: 'png' } });
|
|
expect(calls).toHaveLength(1);
|
|
expect(result).toMatchObject({
|
|
details: {
|
|
schema: 'agent-browser.v1', action: 'send_cdp',
|
|
attachmentId: 'attachment-a', mime: 'image/png',
|
|
},
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain(Buffer.from('png-data').toString('base64'));
|
|
expect((await attachments.read('attachment-a')).data.toString()).toBe('png-data');
|
|
});
|
|
|
|
it('forwards the explicit preview data opt-in from the agent browser tool', async () => {
|
|
const root = await temporaryRoot('makelore-pi-browser-preview-tool-');
|
|
const attachments = new CodingAttachmentStore(path.join(root, 'attachments'));
|
|
const open = vi.fn().mockResolvedValue({
|
|
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
|
state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', title: 'App',
|
|
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
|
|
});
|
|
const browser = { open } as unknown as AgentBrowserModule;
|
|
const tools = new PiProductTools({
|
|
browser,
|
|
attachments,
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
});
|
|
|
|
await tools.execute('agent_browser', {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
|
projectId: 'project-a', projectPath: root, skillIds: [],
|
|
}, { action: 'open', url: 'http://127.0.0.1:4173/', injectProjectData: true });
|
|
|
|
expect(open).toHaveBeenCalledWith(expect.objectContaining({
|
|
projectId: 'project-a', projectPath: root,
|
|
url: 'http://127.0.0.1:4173/', visible: false,
|
|
injectProjectData: true,
|
|
}));
|
|
});
|
|
|
|
it('presents an agent-opened browser before returning and releases diagnostics with the run', async () => {
|
|
const root = await temporaryRoot('makelore-pi-browser-present-');
|
|
const hidden = {
|
|
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
|
state: 'attached' as const, generation: 3, url: 'http://127.0.0.1:4173/', title: 'App',
|
|
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
|
|
};
|
|
const visible = {
|
|
...hidden,
|
|
visible: true,
|
|
bounds: { x: 10, y: 20, width: 800, height: 600 },
|
|
};
|
|
const order: string[] = [];
|
|
const browser = {
|
|
open: vi.fn(async () => {
|
|
order.push('open');
|
|
return hidden;
|
|
}),
|
|
waitForPresentation: vi.fn(async () => {
|
|
order.push('wait');
|
|
return visible;
|
|
}),
|
|
setDiagnostics: vi.fn(async () => visible),
|
|
} as unknown as AgentBrowserModule;
|
|
const requestAgentBrowserPresentation = vi.fn(() => {
|
|
order.push('show');
|
|
});
|
|
const tools = new PiProductTools({
|
|
browser,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
requestAgentBrowserPresentation,
|
|
});
|
|
const result = await tools.execute('agent_browser', {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
|
projectId: 'project-a', projectPath: root, skillIds: [],
|
|
}, { action: 'open', url: 'http://127.0.0.1:4173/' });
|
|
|
|
expect(order).toEqual(['open', 'show', 'wait']);
|
|
expect(browser.open).toHaveBeenCalledWith(expect.objectContaining({
|
|
diagnosticsOwner: 'agent:conversation-a:run-a',
|
|
}));
|
|
expect(browser.waitForPresentation).toHaveBeenCalledWith({
|
|
projectPath: root,
|
|
generation: 3,
|
|
timeoutMs: 5_000,
|
|
});
|
|
expect(JSON.parse(result.content[0].text)).toMatchObject({ visible: true });
|
|
|
|
await tools.settleRun('conversation-a', 'run-a');
|
|
expect(browser.setDiagnostics).toHaveBeenCalledWith({
|
|
projectPath: root,
|
|
enabled: false,
|
|
owner: 'agent:conversation-a:run-a',
|
|
});
|
|
});
|
|
|
|
it('closes an agent-opened browser when presentation times out', async () => {
|
|
const root = await temporaryRoot('makelore-pi-browser-timeout-');
|
|
const hidden = {
|
|
browserId: 'browser-a', projectId: 'project-a', projectPath: root,
|
|
state: 'attached' as const, generation: 4, url: 'http://127.0.0.1:4173/', title: 'App',
|
|
visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0,
|
|
};
|
|
const close = vi.fn(async () => ({ ...hidden, state: 'closed' as const }));
|
|
const browser = {
|
|
open: vi.fn(async () => hidden),
|
|
waitForPresentation: vi.fn(async () => {
|
|
throw new AgentBrowserFault(
|
|
'VIEWPORT_NOT_READY',
|
|
'开发浏览器显示区域没有及时准备好。',
|
|
true,
|
|
hidden.generation,
|
|
);
|
|
}),
|
|
close,
|
|
} as unknown as AgentBrowserModule;
|
|
const tools = new PiProductTools({
|
|
browser,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
requestAgentBrowserPresentation: vi.fn(),
|
|
});
|
|
|
|
await expect(tools.execute('agent_browser', {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'browser-a',
|
|
projectId: 'project-a', projectPath: root, skillIds: [],
|
|
}, { action: 'open', url: 'http://127.0.0.1:4173/' })).rejects.toMatchObject({
|
|
code: 'VIEWPORT_NOT_READY',
|
|
});
|
|
expect(close).toHaveBeenCalledWith(root);
|
|
});
|
|
|
|
it('dispatches all Data Service tools only through the capability registry', async () => {
|
|
const root = await temporaryRoot('makelore-pi-data-tools-');
|
|
const invoke = vi.fn().mockImplementation(({ toolName, context }) => Promise.resolve({
|
|
content: [{ type: 'text', text: toolName }],
|
|
details: {
|
|
schema: 'makelore-capability.v1', operation: toolName,
|
|
plugin_id: 'makelore.data-service', request_id: `pi:${context.runId}:${context.resourceId}`,
|
|
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1',
|
|
success: true, status: 200, data: { delegated: true },
|
|
},
|
|
}));
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistry,
|
|
});
|
|
const context = {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
|
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
|
};
|
|
|
|
await tools.execute('data_service_configure', context, { collections: ['todos'] });
|
|
await tools.execute('data_service_inspect', context, {});
|
|
await tools.execute('data_service_list_projects', context, {});
|
|
await tools.execute('data_service_get_document', context, {
|
|
collection: 'todos', document_id: 'one',
|
|
});
|
|
await tools.execute('data_service_list_documents', context, {
|
|
collection: 'todos', limit: 50, cursor: 'cursor-a',
|
|
});
|
|
await tools.execute('data_service_put_document', context, {
|
|
collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1,
|
|
});
|
|
await tools.execute('data_service_delete_document', context, {
|
|
collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true,
|
|
});
|
|
await tools.execute('data_service_remove_collection', context, {
|
|
collection: 'todos', confirmed: true,
|
|
});
|
|
await tools.execute('data_service_reset', context, { confirmed: true });
|
|
const removed = await tools.execute('data_service_remove_project', context, { confirmed: true });
|
|
|
|
expect(invoke).toHaveBeenCalledTimes(DATA_SERVICE_PLUGIN_DEFINITION.tools.length);
|
|
expect(invoke.mock.calls.map(([input]) => input.toolName)).toEqual(
|
|
DATA_SERVICE_PLUGIN_DEFINITION.tools.map(({ name }) => name),
|
|
);
|
|
expect(removed.details).toMatchObject({
|
|
schema: 'makelore-capability.v1', operation: 'data_service_remove_project',
|
|
plugin_id: 'makelore.data-service', request_id: 'pi:run-a:resource-a',
|
|
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1',
|
|
success: true, status: 200, data: { delegated: true },
|
|
});
|
|
expect(JSON.stringify(removed)).not.toContain(root);
|
|
});
|
|
|
|
it('delegates any non-core product tool to the capability registry', async () => {
|
|
const root = await temporaryRoot('makelore-pi-generic-plugin-');
|
|
const invoke = vi.fn().mockResolvedValue({
|
|
content: [{ type: 'text', text: 'plugin-result' }],
|
|
details: {
|
|
schema: 'makelore-capability.v1', plugin_id: 'makelore.example', plugin_version: '1.0.0',
|
|
capability_id: 'example.capability', operation: 'run', request_id: 'pi:run-a:resource-a',
|
|
success: true, status: 200, code: null, error: null, retryable: false,
|
|
billing: { mode: 'included', status: 'included' }, payload_schema: 'example.v1', data: {},
|
|
},
|
|
});
|
|
const registry = { invoke } as unknown as CodingCapabilityRegistry;
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
capabilityRegistry: registry,
|
|
});
|
|
const context = {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
|
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
|
};
|
|
await tools.execute('example_tool', context, { value: 1 });
|
|
expect(invoke).toHaveBeenCalledWith({
|
|
toolName: 'example_tool', context, workerRole: 'parent', effectiveSkillIds: [], value: { value: 1 },
|
|
});
|
|
});
|
|
|
|
it('routes Web Search through the model tool registry before the Plugin registry', async () => {
|
|
const root = await temporaryRoot('makelore-pi-model-tool-');
|
|
const modelInvoke = vi.fn().mockResolvedValue({
|
|
content: [{ type: 'text', text: 'current answer' }],
|
|
details: {
|
|
schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded',
|
|
modelId: 'deepseek-v4-pro', answer: 'current answer', sources: [],
|
|
sourceMode: 'inline-or-structured',
|
|
},
|
|
});
|
|
const pluginInvoke = vi.fn();
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
modelToolRegistry: { invoke: modelInvoke } as unknown as ModelToolRegistryPort,
|
|
capabilityRegistry: { invoke: pluginInvoke } as unknown as CodingCapabilityRegistry,
|
|
});
|
|
const context = {
|
|
conversationId: 'conversation-a', workerGeneration: 4,
|
|
runId: 'run-a', resourceId: 'resource-a',
|
|
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
|
};
|
|
|
|
await tools.execute('web_search', context, { query: 'current fact' });
|
|
|
|
expect(modelInvoke).toHaveBeenCalledWith(
|
|
'web_search',
|
|
{
|
|
conversationId: 'conversation-a', workerGeneration: 4,
|
|
runId: 'run-a', resourceId: 'resource-a',
|
|
},
|
|
{ query: 'current fact' },
|
|
);
|
|
expect(pluginInvoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes conversation-only package management before the Plugin registry', async () => {
|
|
const root = await temporaryRoot('makelore-pi-device-package-tool-');
|
|
const invoke = vi.fn().mockResolvedValue({
|
|
content: [{ type: 'text', text: '[]' }],
|
|
details: {
|
|
schema: 'makelore-device-package.v1', operation: 'list', success: true,
|
|
index: { schemaVersion: 1, generation: 0, packages: [] },
|
|
},
|
|
});
|
|
const pluginInvoke = vi.fn();
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
devicePackageTools: { invoke } as unknown as DevicePackageTools,
|
|
capabilityRegistry: { invoke: pluginInvoke } as unknown as CodingCapabilityRegistry,
|
|
});
|
|
const context = {
|
|
conversationId: 'conversation-a', workerGeneration: 4,
|
|
runId: 'run-a', resourceId: 'resource-a',
|
|
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
|
};
|
|
|
|
await tools.execute('local_package_list', context, {});
|
|
|
|
expect(invoke).toHaveBeenCalledWith('local_package_list', 'run-a', {});
|
|
expect(pluginInvoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not fabricate Data Service validation or billing without a registry', async () => {
|
|
const root = await temporaryRoot('makelore-pi-data-input-');
|
|
const tools = new PiProductTools({
|
|
browser: {} as AgentBrowserModule,
|
|
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
|
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
|
});
|
|
const context = {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
|
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
|
};
|
|
|
|
await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' }))
|
|
.rejects.toThrow('Product tool is unavailable');
|
|
});
|
|
});
|