// @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 { 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 { DataServiceOperations } from '../../electron/services/data-service-client'; 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 { const root = await mkdtemp(path.join(tmpdir(), prefix)); roots.push(root); return root; } async function git(root: string, ...args: string[]): Promise { await exec('git', ['-C', root, ...args], { windowsHide: true }); } async function initializeRepository(root: string): Promise { 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(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(); 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('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:'); }); 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: 'makelore-capability.v1', operation: '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: { removed: 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('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' })).resolves.toMatchObject({ details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 }, }); await expect(tools.execute('data_service_put_document', context, { collection: 'todos', document_id: 'one', data: {}, path: root, })).resolves.toMatchObject({ details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 }, }); await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).resolves.toMatchObject({ details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 }, }); expect(dataService.inspect).not.toHaveBeenCalled(); expect(dataService.removeProject).not.toHaveBeenCalled(); }); });