feat(coding): add Pi Data Service product tools
This commit is contained in:
@@ -15,9 +15,24 @@ import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown;
|
||||
type ExtensionTool = {
|
||||
name: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
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[] = [];
|
||||
|
||||
@@ -209,7 +224,26 @@ describe('Makelore Pi extension bundle', () => {
|
||||
expect([...tools.keys()]).toEqual([
|
||||
'ask_user', 'subagent', 'agent_browser', 'game_asset_browser',
|
||||
'game_asset_review', '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).toMatchObject({
|
||||
type: 'object', additionalProperties: false,
|
||||
});
|
||||
expect(Object.keys(tool?.parameters?.properties ?? {})).not.toEqual(
|
||||
expect.arrayContaining(['owner', 'project', 'path', 'token', 'endpoint', 'url', 'handle']),
|
||||
);
|
||||
}
|
||||
|
||||
const updates: unknown[] = [];
|
||||
const subagentResult = await tools.get('subagent')?.execute?.(
|
||||
@@ -317,6 +351,12 @@ describe('Makelore Pi extension bundle', () => {
|
||||
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' },
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} 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 { DataServiceOperations } from '../../electron/services/data-service-client';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
@@ -231,6 +232,36 @@ describe('PI-090 product tools', () => {
|
||||
schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'attachment-a',
|
||||
})).toBeNull();
|
||||
expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull();
|
||||
expect(productToolDetails({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_inspect',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: { instance_id: 'instance-a' },
|
||||
owner: 'must-be-dropped',
|
||||
})).toEqual({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_inspect',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: { instance_id: 'instance-a' },
|
||||
});
|
||||
expect(productToolDetails({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_unknown',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('stores browser screenshots as attachment ids and never returns base64', async () => {
|
||||
@@ -288,4 +319,108 @@ describe('PI-090 product tools', () => {
|
||||
expect(JSON.stringify(result)).not.toContain(root);
|
||||
expect(JSON.stringify(result)).not.toContain('data:');
|
||||
});
|
||||
|
||||
it('dispatches all Data Service tools through the shared adapter and trusted project path', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-tools-');
|
||||
const response = (data: unknown) => ({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data,
|
||||
});
|
||||
const dataService = {
|
||||
configure: vi.fn().mockResolvedValue(response({ configured: true })),
|
||||
inspect: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })),
|
||||
listProjects: vi.fn().mockResolvedValue(response({ items: [], total: 0, instance_limit: 20 })),
|
||||
getDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })),
|
||||
listDocuments: vi.fn().mockResolvedValue(response({ items: [], next_cursor: null, limit: 50 })),
|
||||
putDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })),
|
||||
deleteDocument: vi.fn().mockResolvedValue(response(null)),
|
||||
removeCollection: vi.fn().mockResolvedValue(response({ removed: true, usage: { document_count: 0, total_bytes: 0 } })),
|
||||
reset: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })),
|
||||
removeProject: vi.fn().mockResolvedValue(response({ removed: true })),
|
||||
} as unknown as DataServiceOperations;
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
dataService,
|
||||
});
|
||||
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(dataService.configure).toHaveBeenCalledWith({ collections: ['todos'] }, root);
|
||||
expect(dataService.inspect).toHaveBeenCalledWith(root);
|
||||
expect(dataService.listProjects).toHaveBeenCalledWith();
|
||||
expect(dataService.getDocument).toHaveBeenCalledWith({ collection: 'todos', document_id: 'one' }, root);
|
||||
expect(dataService.listDocuments).toHaveBeenCalledWith({
|
||||
collection: 'todos', limit: 50, cursor: 'cursor-a',
|
||||
}, root);
|
||||
expect(dataService.putDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1,
|
||||
}, root);
|
||||
expect(dataService.deleteDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true,
|
||||
}, root);
|
||||
expect(dataService.removeCollection).toHaveBeenCalledWith({ collection: 'todos', confirmed: true }, root);
|
||||
expect(dataService.reset).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(dataService.removeProject).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(removed.details).toMatchObject({
|
||||
schema: 'data-service.v1', operation: 'data_service_remove_project',
|
||||
success: true, status: 200, data: { removed: true },
|
||||
});
|
||||
expect(JSON.stringify(removed)).not.toContain(root);
|
||||
});
|
||||
|
||||
it('rejects forbidden tool fields and destructive calls without literal confirmation', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-input-');
|
||||
const dataService = {
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
success: true, status: 200, code: null, error: null, retryable: false, data: null,
|
||||
}),
|
||||
removeProject: vi.fn(),
|
||||
} as unknown as DataServiceOperations;
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
dataService,
|
||||
});
|
||||
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(
|
||||
'Data Service tool input is invalid',
|
||||
);
|
||||
await expect(tools.execute('data_service_put_document', context, {
|
||||
collection: 'todos', document_id: 'one', data: {}, path: root,
|
||||
})).rejects.toThrow('Data Service tool input is invalid');
|
||||
await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).rejects.toThrow(
|
||||
'Data Service tool input is invalid',
|
||||
);
|
||||
expect(dataService.inspect).not.toHaveBeenCalled();
|
||||
expect(dataService.removeProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user