feat: implement PI-090 product tools
This commit is contained in:
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { loadGameAssetCandidates } from '@electron/opencode/game-asset-browser';
|
||||
import { loadGameAssetCandidates } from '@electron/coding-projects/game-asset-browser';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
loadGameAssetReview,
|
||||
recordGameAssetReviewActions,
|
||||
recordGameAssetReviewAction,
|
||||
} from '@electron/opencode/game-asset-review';
|
||||
} from '@electron/coding-projects/game-asset-review';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
|
||||
@@ -104,6 +104,56 @@ describe('Pi event projector', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('projects versioned product-tool details and suppresses unknown versions', async () => {
|
||||
const projector = new PiEventProjector({ createId: () => 'unused' });
|
||||
let snapshot = emptySnapshot();
|
||||
snapshot.nodes.push({
|
||||
kind: 'tool', id: 'tool-task-state', toolCallId: 'call-task-state', toolName: 'task_state',
|
||||
title: 'task_state', inputText: '{}', status: 'running', output: [],
|
||||
});
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'call-task-state',
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
details: {
|
||||
schema: 'task-state.v1',
|
||||
tasks: [{ id: 'task-a', title: 'Implement product tools', status: 'complete' }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'tool',
|
||||
id: 'tool-task-state',
|
||||
details: {
|
||||
schema: 'task-state.v1',
|
||||
tasks: [{ id: 'task-a', title: 'Implement product tools', status: 'complete' }],
|
||||
},
|
||||
}));
|
||||
|
||||
snapshot.nodes.push({
|
||||
kind: 'tool', id: 'tool-browser', toolCallId: 'call-browser', toolName: 'agent_browser',
|
||||
title: 'agent_browser', inputText: '{}', status: 'running', output: [],
|
||||
});
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'tool_execution_end',
|
||||
toolCallId: 'call-browser',
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: 'text', text: 'RAW_BROWSER_OUTPUT' }],
|
||||
details: { schema: 'agent-browser.v2', attachmentId: 'RAW_ATTACHMENT' },
|
||||
},
|
||||
}));
|
||||
expect(JSON.stringify(snapshot)).not.toContain('RAW_BROWSER_OUTPUT');
|
||||
expect(JSON.stringify(snapshot)).not.toContain('RAW_ATTACHMENT');
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'tool',
|
||||
id: 'tool-browser',
|
||||
output: [expect.objectContaining({ text: 'Tool details are unavailable for this version.' })],
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps one assistant UI identity while content-index deltas become an authoritative message', async () => {
|
||||
const projector = new PiEventProjector({
|
||||
createId: () => 'assistant-ui-a',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
@@ -8,6 +8,9 @@ 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 = {
|
||||
@@ -24,6 +27,98 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
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');
|
||||
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,
|
||||
};
|
||||
},
|
||||
} as unknown as AgentBrowserModule;
|
||||
const host = new PiManagedExtensionHost();
|
||||
const productTools = new PiProductTools({
|
||||
browser,
|
||||
attachments: new CodingAttachmentStore(path.join(root, '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({ 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);
|
||||
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);
|
||||
@@ -69,7 +164,10 @@ describe('Makelore Pi extension bundle', () => {
|
||||
registerTool: (tool) => tools.set(tool.name, tool),
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
});
|
||||
expect([...tools.keys()]).toEqual(['ask_user', 'subagent']);
|
||||
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?.(
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('managed Pi worker opener', () => {
|
||||
expect(argv).toContain('grilling');
|
||||
expect(argv).toContain('--session-id');
|
||||
expect(argv).toContain('--extension');
|
||||
expect(argv).toContain('makelore-runtime-v2.mjs');
|
||||
expect(argv).toContain('makelore-runtime-v3.mjs');
|
||||
expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(1);
|
||||
expect(argv).not.toContain('PRIVATE MANAGED PROMPT');
|
||||
expect(argv).not.toContain('provider-secret-value');
|
||||
|
||||
212
tests/unit/pi-product-tools.test.ts
Normal file
212
tests/unit/pi-product-tools.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, rm, 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 type { AgentBrowserModule } from '../../electron/agent-browser';
|
||||
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
|
||||
import { ConversationChangeTracker } 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';
|
||||
|
||||
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 previews without hiding append-only 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)}tail`, 'utf8');
|
||||
await tracker.markProjectRefresh('conversation-a', 'run-a');
|
||||
const snapshot = await tracker.settleRun('conversation-a', 'run-a');
|
||||
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('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(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('accepts only safe versioned product detail projections', () => {
|
||||
expect(productToolDetails({
|
||||
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/project.json'],
|
||||
})).toEqual({
|
||||
schema: 'changed-file.v1', paths: ['src/app.ts', '.niancode/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();
|
||||
});
|
||||
|
||||
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('loads game asset review state through the vendor-neutral product module', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-game-tool-');
|
||||
await writeFile(path.join(root, 'ASSET_PLAN.md'), [
|
||||
'```json',
|
||||
JSON.stringify({ assets: [{ id: 'hero', name: 'Hero', category: 'visual', status: 'candidate' }] }),
|
||||
'```',
|
||||
].join('\n'), 'utf8');
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
});
|
||||
const result = await tools.execute('game_asset_browser', {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'review-a',
|
||||
projectId: 'project-a', projectPath: root, skillIds: [],
|
||||
}, {});
|
||||
expect(result.details).toEqual({
|
||||
schema: 'game-assets.v1', invocationId: 'review-a', candidateIds: ['hero'],
|
||||
status: 'pending', pendingAssetIds: ['hero'], approvedAssetIds: [], discardedAssetIds: [],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain(root);
|
||||
expect(JSON.stringify(result)).not.toContain('data:');
|
||||
});
|
||||
});
|
||||
@@ -167,7 +167,7 @@ describe('Pi worker process', () => {
|
||||
'--no-context-files',
|
||||
'--no-approve',
|
||||
'--tools',
|
||||
'read,bash,edit,write,grep,find,ls,ask_user,subagent',
|
||||
'read,bash,edit,write,grep,find,ls,ask_user,subagent,agent_browser,game_asset_browser,game_asset_review,task_state,changed_file,runtime_context',
|
||||
'--model', 'model-a',
|
||||
]);
|
||||
expect(buildPiRpcArgs('sessions', ['--no-session'], ['read', 'grep', 'find', 'ls']))
|
||||
|
||||
@@ -84,6 +84,54 @@ describe('Pi session projector', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('hydrates product-tool details and never restores unknown raw details', async () => {
|
||||
const snapshot = await projectPiSessionSnapshot({
|
||||
snapshot: baseSnapshot(),
|
||||
workerGeneration: 1,
|
||||
state: { sessionId: 'session-a', isStreaming: false, isCompacting: false },
|
||||
entries: {
|
||||
leafId: 'entry-unknown-result',
|
||||
entries: [
|
||||
{
|
||||
type: 'message', id: 'entry-assistant', parentId: null,
|
||||
message: {
|
||||
role: 'assistant', stopReason: 'toolUse', usage: { input: 1, output: 1 },
|
||||
content: [
|
||||
{ type: 'toolCall', id: 'call-known', name: 'changed_file', arguments: {} },
|
||||
{ type: 'toolCall', id: 'call-unknown', name: 'runtime_context', arguments: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message', id: 'entry-known-result', parentId: 'entry-assistant',
|
||||
message: {
|
||||
role: 'toolResult', toolCallId: 'call-known', toolName: 'changed_file', content: [],
|
||||
details: { schema: 'changed-file.v1', paths: ['src/example.ts'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message', id: 'entry-unknown-result', parentId: 'entry-known-result',
|
||||
message: {
|
||||
role: 'toolResult', toolCallId: 'call-unknown', toolName: 'runtime_context', content: [],
|
||||
details: { schema: 'runtime-context.v9', raw: 'RAW_CONTEXT_SECRET' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'tool',
|
||||
toolCallId: 'call-known',
|
||||
details: { schema: 'changed-file.v1', paths: ['src/example.ts'] },
|
||||
}));
|
||||
expect(JSON.stringify(snapshot)).not.toContain('RAW_CONTEXT_SECRET');
|
||||
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
|
||||
kind: 'tool',
|
||||
toolCallId: 'call-unknown',
|
||||
output: [expect.objectContaining({ text: 'Tool details are unavailable for this version.' })],
|
||||
}));
|
||||
});
|
||||
|
||||
it('hydrates only the authoritative active leaf path', async () => {
|
||||
const snapshot = await projectPiSessionSnapshot({
|
||||
snapshot: baseSnapshot(),
|
||||
|
||||
@@ -105,7 +105,16 @@ describe('locked Pi worker process smoke', () => {
|
||||
success: true,
|
||||
});
|
||||
expect(worker.stderrDiagnostic).not.toContain('Failed to load extension');
|
||||
expect(await readActiveTools(probe.resultPath)).toContain('subagent');
|
||||
expect(await readActiveTools(probe.resultPath)).toEqual(expect.arrayContaining([
|
||||
'ask_user',
|
||||
'subagent',
|
||||
'agent_browser',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
]));
|
||||
await expect(worker.stop()).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
|
||||
} finally {
|
||||
await worker.stop().catch(() => undefined);
|
||||
@@ -233,4 +242,64 @@ describe('locked Pi worker process smoke', () => {
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
it.skipIf(!packagedRuntimeRoot)(
|
||||
'loads product tools in the staged production-closure parent runtime',
|
||||
async () => {
|
||||
const requireFromProject = createRequire(resolve('package.json'));
|
||||
const electronExecutable = requireFromProject('electron') as string;
|
||||
const packageRoot = realpathSync(packagedRuntimeRoot as string);
|
||||
const root = await mkdtemp(join(tmpdir(), 'makelore-pi-staged-product-tools-'));
|
||||
scratchRoots.push(root);
|
||||
const configDir = join(root, 'config');
|
||||
const sessionDir = join(root, 'sessions');
|
||||
const cwd = join(root, 'project');
|
||||
await Promise.all([mkdir(configDir), mkdir(sessionDir), mkdir(cwd)]);
|
||||
const extensionHost = new PiManagedExtensionHost();
|
||||
const extension = await extensionHost.registerWorker({
|
||||
conversationId: 'staged-product-tools-parent',
|
||||
generation: 1,
|
||||
projectId: 'staged-project',
|
||||
extensionsDir: join(root, 'extensions'),
|
||||
projectPath: cwd,
|
||||
});
|
||||
await extensionHost.bindRun('staged-product-tools-parent', 1, 'staged-run');
|
||||
const probe = await materializeActiveToolsProbe(root);
|
||||
const worker = new PiWorkerProcess({
|
||||
executablePath: electronExecutable,
|
||||
cliPath: join(packageRoot, 'dist', 'cli.js'),
|
||||
cwd,
|
||||
configDir,
|
||||
sessionDir,
|
||||
additionalArgs: [
|
||||
'--extension', extension.extensionPath,
|
||||
'--extension', probe.extensionPath,
|
||||
],
|
||||
env: { ...extension.env, MAKELORE_PI_ACTIVE_TOOLS_FILE: probe.resultPath },
|
||||
sensitiveValues: extension.sensitiveValues,
|
||||
commandTimeoutMs: 5_000,
|
||||
});
|
||||
try {
|
||||
await worker.start();
|
||||
await expect(worker.request({ type: 'get_state' })).resolves.toMatchObject({
|
||||
type: 'response', command: 'get_state', success: true,
|
||||
});
|
||||
expect(await readActiveTools(probe.resultPath)).toEqual(expect.arrayContaining([
|
||||
'agent_browser',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
]));
|
||||
expect(worker.stderrDiagnostic).not.toContain('Failed to load extension');
|
||||
await expect(worker.stop()).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
|
||||
} finally {
|
||||
await worker.stop().catch(() => undefined);
|
||||
await extension.dispose();
|
||||
await extensionHost.close();
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user