From 13ab383e53dedd0c0c54f201c2237200af63080e Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 23 Aug 2026 15:14:50 +0800 Subject: [PATCH] feat: implement PI-090 product tools --- .../20260823-pi-product-tools-a9c4e7d2.md | 145 +++++++ electron/api/routes/files.ts | 4 +- electron/coding-projects/attachment-store.ts | 78 ++++ .../conversation-change-tracker.ts | 357 ++++++++++++++++++ .../game-asset-browser.ts | 0 .../game-asset-review.ts | 0 electron/coding-projects/skill-registry.ts | 100 +++++ electron/coding-runtime/contracts.ts | 37 ++ .../coding-runtime/conversation-reducer.ts | 14 +- electron/coding-runtime/pi/event-projector.ts | 21 +- electron/coding-runtime/pi/extension-host.ts | 77 +++- .../pi/extensions/agent-browser.ts | 158 ++++++++ .../pi/extensions/changed-file.ts | 35 ++ .../pi/extensions/game-assets.ts | 85 +++++ .../pi/extensions/makelore-runtime.ts | 138 ++++++- .../pi/extensions/task-state.ts | 41 ++ electron/coding-runtime/pi/product-tools.ts | 102 +++++ electron/coding-runtime/pi/runtime.ts | 2 + .../coding-runtime/pi/session-projector.ts | 20 +- electron/coding-runtime/pi/subagent-child.ts | 2 + electron/coding-runtime/pi/worker-process.ts | 2 + .../coding-runtime/product-tool-protocol.ts | 156 ++++++++ .../coding-skills/agent-browser/SKILL.md | 19 +- tests/unit/game-asset-browser.test.ts | 2 +- tests/unit/game-asset-review.test.ts | 2 +- tests/unit/pi-event-projector.test.ts | 50 +++ tests/unit/pi-extension-bundle.test.ts | 102 ++++- tests/unit/pi-managed-worker-opener.test.ts | 2 +- tests/unit/pi-product-tools.test.ts | 212 +++++++++++ tests/unit/pi-rpc-foundation.test.ts | 2 +- tests/unit/pi-session-projector.test.ts | 48 +++ tests/unit/pi-worker-process-real.test.ts | 71 +++- 32 files changed, 2035 insertions(+), 49 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260823-pi-product-tools-a9c4e7d2.md create mode 100644 electron/coding-projects/attachment-store.ts create mode 100644 electron/coding-projects/conversation-change-tracker.ts rename electron/{opencode => coding-projects}/game-asset-browser.ts (100%) rename electron/{opencode => coding-projects}/game-asset-review.ts (100%) create mode 100644 electron/coding-projects/skill-registry.ts create mode 100644 electron/coding-runtime/pi/extensions/agent-browser.ts create mode 100644 electron/coding-runtime/pi/extensions/changed-file.ts create mode 100644 electron/coding-runtime/pi/extensions/game-assets.ts create mode 100644 electron/coding-runtime/pi/extensions/task-state.ts create mode 100644 electron/coding-runtime/pi/product-tools.ts create mode 100644 electron/coding-runtime/product-tool-protocol.ts create mode 100644 tests/unit/pi-product-tools.test.ts diff --git a/.project-docs/30-worklog/tasks/20260823-pi-product-tools-a9c4e7d2.md b/.project-docs/30-worklog/tasks/20260823-pi-product-tools-a9c4e7d2.md new file mode 100644 index 0000000..0683904 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260823-pi-product-tools-a9c4e7d2.md @@ -0,0 +1,145 @@ +# Task: Implement PI-090 product tools and change tracker + +## Identity + +- Task ID: 20260823-pi-product-tools-a9c4e7d2 +- Mode: Feature +- Branch: codex/20260823-pi-product-tools-a9c4e7d2-pi-product-tools +- Worktree: D:\Datas\OthersProjects\makelore-pi-product-tools-a9c4e7d2 +- Base commit: 77cdee7e73feff8f3941afcfd280e6dedd83b73b +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement `PI-090 — Product tools and Conversation change tracker` from the + `【架构】opencode2pi` Spec/ticket graph on cumulative PI-080 baseline + `77cdee7e73feff8f3941afcfd280e6dedd83b73b`. +- Own the product-side Pi tools for browser, game-asset browse/review, + versioned task state, relative changed-file reporting, selected skills and + command catalog, plus a Main-owned Git/status/diff Conversation tracker. +- Reuse the existing Main Agent Browser seam and migrate retained game-asset + and skill semantics out of OpenCode-specific ownership without wiring the + PI-105 Host routes or PI-130 Renderer UI. + +## Intent And Constraints + +- Browser commands must call the existing Main-owned browser module directly; + they must not launch `@playwright/mcp`, discover MCP servers, or introduce a + second browser protocol. Screenshot results use bounded attachment identity, + never repeated base64 projection to Renderer. +- Product tool details are versioned and strictly normalized. `task_state` is + not an old todo endpoint, `changed_file` accepts only project-relative paths, + and changes never come from parsing Pi message text. +- Record run-start Git head/status/diff baselines, refresh precise touched paths + after write/edit, and perform one project-level refresh after bash/settle. + Untracked preview is bounded and contains no absolute path. Do not add file + hashes, checkpoints, rollback semantics, or a generic VCS framework. +- Selected skills and commands come only from the explicit bundled registry and + project Agent selection; project/user `.pi` discovery and OpenCode plugin + types remain excluded. +- Keep the extension/bridge Main-private and authenticated by the existing + worker token + conversation/generation/run identity. Child tool exposure must + stay within PI-080's explicit role/tool policy. +- Real external Provider validation remains **Explicitly Waived / Accepted + Risk** with `realTurnVerified=false`; macOS x64/arm64 remains deferred to + mandatory PI-150 validation. Neither is a Pass. +- Do not create subagents. Keep all writes in this isolated worktree and leave + canonical project memory to the later integration ticket. + +## Plan + +1. Inspect and lock the smallest interfaces across the Pi extension bridge, + existing Agent Browser module, retained game-asset logic, explicit skill + resources, and Git/project seams. +2. Add the versioned product tools and bridge adapters, reuse the Main browser + module, and rehome retained game-asset behavior without MCP or OpenCode + plugin dependencies. +3. Add the selected skill/command registry and Conversation change tracker with + relative paths, bounded previews, touched-path refresh and bash/project + refresh semantics. +4. Add focused real/fake bundle, bridge, browser/game, registry and + git/no-git/dirty/untracked/bash tracker tests plus the applicable packaged + smoke. +5. Run focused Pi tests, typecheck, lint, full unit, Vite build and project-doc + gates; commit the exact owned scope and request planner review. + +## Outcome + +- Added the versioned `makelore-runtime-v3.mjs` parent extension surface for + `agent_browser`, `game_asset_browser`, `game_asset_review`, `task_state`, + `changed_file`, and `runtime_context`. Product calls cross the existing + loopback worker-token bridge with conversation/generation/run validation; + child workers do not register or invoke these parent product tools. +- Added `PiProductTools` and direct Main-domain adapters. Agent Browser calls + `AgentBrowserModule` without MCP and converts `Page.captureScreenshot` + results into product attachment IDs. Game asset browse/review behavior now + lives under vendor-neutral `electron/coding-projects` ownership while the + current OpenCode Host route temporarily consumes the same module. +- Added strict v1 typed details for browser, game assets, task state, + changed files, and runtime context. Live projection and cold session + hydration accept only normalized known versions and replace unknown raw + details with bounded generic text. +- Added `ConversationChangeTracker`: each run captures Git HEAD plus dirty + status/diff/untracked baseline; write/edit reports refresh exact relative + paths, bash requests a settled project-wide refresh, and no-Git projects use + only explicitly touched paths. Diffs and untracked previews are bounded, + absolute paths are rejected, and no hashes/checkpoints/rollback mechanism + were introduced. +- Added an explicit bundled Skill registry and Makelore/Pi/selected-Skill + command catalog projection. Only packaged Skill IDs and safe metadata are + exposed; project/user auto-discovery remains disabled. +- Extended the real Pi and staged production-closure smoke to require all + parent product tools while retaining the exact read-only child tool set. +- No Renderer or `/api/coding` route was added; PI-105/PI-130 remain the owners + of Host API and UI consumption. `README.md` therefore still describes the + unchanged current product cutover state and did not require an update here. + +## Verification + +- `corepack pnpm run typecheck`: passed. +- `corepack pnpm run lint:check`: passed with the same six repository warnings + and no errors (`ExecutionGraphCard.tsx`, `Home/index.tsx`, and + `Makelore/index.tsx`; none are in this task's changed scope). +- Focused PI-090/Pi projection/bridge/game suite: 10 files, 61 tests passed + before the final boundary additions; subsequent focused runs covered 27 and + 9 tests respectively and passed. +- `corepack pnpm test`: 203 files passed; 2224 tests passed, 2 skipped. +- `corepack pnpm run build:vite`: passed for Renderer, Electron Main, Preload, + and release utility outputs; existing chunk-size/dynamic-import warnings + remain non-blocking. +- `corepack pnpm run test:electron:windows`: 1 file, 3 tests passed. +- `corepack pnpm run test:pi-subagent:packaged`: 1 file, 4 tests passed, + including staged parent product-tool registration and the unchanged staged + read-only child boundary. +- Real external Provider turns remain **Explicitly Waived / Accepted Risk**; + `realTurnVerified=false`. macOS x64/arm64 validation remains deferred to the + mandatory PI-150 gate. Neither item is recorded as passed. + +## Follow-ups + +- PI-100/PI-105 must construct/configure `PiProductTools` in the production + Main composition, expose Conversation changes, attachment reads, Skills and + command DTOs through `/api/coding`, and keep the new attachment store behind + Main-owned routes. +- PI-130 must consume typed product-tool details and Conversation change + snapshots without inferring changes from message text; fork copy must not + imply filesystem rollback. +- PI-140 may delete the remaining OpenCode-specific consumer/MCP artifacts + only after cutover; the vendor-neutral game asset and Skill sources remain. +- PI-150 must run mandatory macOS x64/arm64 validation. Real external Provider + concurrency, credential isolation and protocol compatibility remain the + explicitly accepted unverified risk until a separately authorized test. + +## Promotion Candidates + +- Target canonical documents: `.project-docs/30-worklog/current-state.md` and + the accepted PI cutover implementation status maintained by the planner. + Proposal: after the implementation commit is reviewed and integrated, mark + PI-090 complete and make PI-105/PI-130 eligible on the cumulative chain. + Evidence: the implementation commit from this task plus the verification + matrix above. Future impact: Host API/UI tickets may consume the typed + product-tool, Skill-command and Conversation-change contracts instead of + OpenCode Session diff/todo/MCP behavior. Semantic conflicts: none known. + Human confirmation required: no for recording the merged implementation + fact; normal planner review and integration ownership still apply. diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts index e95ed94..e8b761c 100644 --- a/electron/api/routes/files.ts +++ b/electron/api/routes/files.ts @@ -6,13 +6,13 @@ import { homedir } from 'node:os'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { readProjectConfig } from '../../opencode/project-config'; -import { loadGameAssetCandidates } from '../../opencode/game-asset-browser'; +import { loadGameAssetCandidates } from '../../coding-projects/game-asset-browser'; import { GameAssetReviewConflictError, loadGameAssetReview, recordGameAssetReviewActions, recordGameAssetReviewAction, -} from '../../opencode/game-asset-review'; +} from '../../coding-projects/game-asset-review'; const EXT_MIME_MAP: Record = { '.png': 'image/png', diff --git a/electron/coding-projects/attachment-store.ts b/electron/coding-projects/attachment-store.ts new file mode 100644 index 0000000..4407ef0 --- /dev/null +++ b/electron/coding-projects/attachment-store.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/; +const MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i; +const DEFAULT_MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024; + +export interface CodingAttachmentRef { + attachmentId: string; + mime: string; + byteLength: number; +} + +export interface CodingAttachmentRecord extends CodingAttachmentRef { + data: Buffer; +} + +export interface CodingAttachmentStoreOptions { + createId?: () => string; + maxBytes?: number; +} + +export class CodingAttachmentStore { + private readonly createId: () => string; + private readonly maxBytes: number; + + constructor( + private readonly rootDir: string, + options: CodingAttachmentStoreOptions = {}, + ) { + this.createId = options.createId ?? randomUUID; + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES; + } + + async put(data: Uint8Array, mime: string): Promise { + const normalizedMime = mime.trim().toLowerCase(); + if (!MIME_PATTERN.test(normalizedMime)) throw new Error('Attachment MIME type is invalid'); + if (data.byteLength === 0 || data.byteLength > this.maxBytes) { + throw new Error('Attachment size is invalid'); + } + const attachmentId = this.createId(); + if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) throw new Error('Attachment id is invalid'); + await mkdir(this.rootDir, { recursive: true }); + const dataPath = path.join(this.rootDir, `${attachmentId}.bin`); + const metadataPath = path.join(this.rootDir, `${attachmentId}.json`); + const temporaryDataPath = `${dataPath}.${randomUUID()}.tmp`; + const temporaryMetadataPath = `${metadataPath}.${randomUUID()}.tmp`; + const reference: CodingAttachmentRef = { + attachmentId, + mime: normalizedMime, + byteLength: data.byteLength, + }; + await writeFile(temporaryDataPath, data); + await writeFile(temporaryMetadataPath, `${JSON.stringify(reference)}\n`, 'utf8'); + await rename(temporaryDataPath, dataPath); + await rename(temporaryMetadataPath, metadataPath); + return reference; + } + + async read(attachmentId: string): Promise { + if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) throw new Error('Attachment id is invalid'); + const metadata = JSON.parse(await readFile( + path.join(this.rootDir, `${attachmentId}.json`), + 'utf8', + )) as CodingAttachmentRef; + if (metadata.attachmentId !== attachmentId + || !MIME_PATTERN.test(metadata.mime) + || !Number.isSafeInteger(metadata.byteLength) + || metadata.byteLength <= 0 + || metadata.byteLength > this.maxBytes) { + throw new Error('Attachment metadata is invalid'); + } + const data = await readFile(path.join(this.rootDir, `${attachmentId}.bin`)); + if (data.byteLength !== metadata.byteLength) throw new Error('Attachment data is incomplete'); + return { ...metadata, data }; + } +} diff --git a/electron/coding-projects/conversation-change-tracker.ts b/electron/coding-projects/conversation-change-tracker.ts new file mode 100644 index 0000000..635f3a0 --- /dev/null +++ b/electron/coding-projects/conversation-change-tracker.ts @@ -0,0 +1,357 @@ +import { spawn } from 'node:child_process'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const MAX_CHANGED_FILES = 200; +const MAX_DIFF_BYTES = 64 * 1024; +const MAX_UNTRACKED_PREVIEW_BYTES = 8 * 1024; +const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024; + +export type ConversationChangedFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'; + +export interface ConversationChangedFile { + path: string; + status: ConversationChangedFileStatus; + diff?: string; + preview?: string; + truncated?: boolean; +} + +export interface ConversationChangesSnapshot { + conversationId: string; + runId: string; + git: boolean; + baselineHead: string | null; + files: ConversationChangedFile[]; +} + +export interface GitCommandResult { + code: number; + stdout: string; +} + +export interface ConversationGitAdapter { + run(projectPath: string, args: readonly string[], signal?: AbortSignal): Promise; +} + +export class ProcessConversationGitAdapter implements ConversationGitAdapter { + run(projectPath: string, args: readonly string[], signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const child = spawn('git', ['-C', projectPath, '--literal-pathspecs', ...args], { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + signal, + env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat' }, + }); + const chunks: Buffer[] = []; + let bytes = 0; + const collect = (chunk: Buffer) => { + bytes += chunk.byteLength; + if (bytes > MAX_GIT_OUTPUT_BYTES) { + child.kill(); + reject(new Error('Git output is too large')); + return; + } + chunks.push(chunk); + }; + child.stdout.on('data', collect); + child.stderr.on('data', () => undefined); + child.once('error', reject); + child.once('close', (code) => resolve({ + code: code ?? 1, + stdout: Buffer.concat(chunks).toString('utf8'), + })); + }); + } +} + +interface StatusEntry { + path: string; + status: ConversationChangedFileStatus; + signature: string; +} + +interface FileState extends StatusEntry { + content: string; + preview?: string; + truncated?: boolean; +} + +interface ChangeBaseline { + git: boolean; + head: string | null; + files: Map; +} + +interface ChangeRunRecord { + conversationId: string; + runId: string; + projectPath: string; + baseline: ChangeBaseline; + touchedPaths: Set; + projectRefresh: boolean; + snapshot: ConversationChangesSnapshot; +} + +function normalizeRelativePath(value: string): string { + const raw = value.trim(); + const trimmed = raw.replaceAll('\\', '/'); + if (!trimmed || trimmed.includes('\0') || path.isAbsolute(raw) || path.win32.isAbsolute(raw) + || path.posix.isAbsolute(trimmed)) { + throw new Error('Changed file path must be project-relative'); + } + const normalized = path.posix.normalize(trimmed).replace(/^\.\//, ''); + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw new Error('Changed file path escapes the project'); + } + return normalized; +} + +function statusFromSignature(signature: string, renamed: boolean): ConversationChangedFileStatus { + if (signature === '??') return 'untracked'; + if (renamed || signature.includes('R')) return 'renamed'; + if (signature.includes('D')) return 'deleted'; + if (signature.includes('A')) return 'added'; + return 'modified'; +} + +function parsePorcelainV2(value: string): StatusEntry[] { + const records = value.split('\0'); + const entries: StatusEntry[] = []; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (!record || record.startsWith('! ')) continue; + if (record.startsWith('? ')) { + const filePath = normalizeRelativePath(record.slice(2)); + entries.push({ path: filePath, status: 'untracked', signature: '??' }); + continue; + } + const renamed = record.startsWith('2 '); + const match = renamed + ? record.match(/^2 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s) + : record.match(/^1 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s); + if (!match) continue; + const signature = match[1]; + const filePath = normalizeRelativePath(match[2]); + entries.push({ + path: filePath, + status: statusFromSignature(signature, renamed), + signature, + }); + if (renamed) index += 1; + } + return entries.slice(0, MAX_CHANGED_FILES); +} + +function boundedText(value: string, maxBytes: number): { text: string; truncated: boolean } { + const data = Buffer.from(value, 'utf8'); + if (data.byteLength <= maxBytes) return { text: value, truncated: false }; + return { text: data.subarray(0, maxBytes).toString('utf8'), truncated: true }; +} + +async function untrackedPreview( + projectPath: string, + relativePath: string, +): Promise<{ preview?: string; content: string; truncated?: boolean }> { + const target = path.resolve(projectPath, ...relativePath.split('/')); + const relative = path.relative(path.resolve(projectPath), target); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Changed file path escapes the project'); + } + try { + const file = await stat(target); + if (!file.isFile()) return { content: 'not-file' }; + const data = await readFile(target); + const bounded = data.subarray(0, MAX_UNTRACKED_PREVIEW_BYTES); + if (bounded.includes(0)) return { content: `binary:${data.byteLength}` }; + const preview = bounded.toString('utf8'); + return { + preview, + content: `text:${data.byteLength}:${preview}`, + ...(data.byteLength > bounded.byteLength ? { truncated: true } : {}), + }; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return { content: 'missing' }; + } + throw error; + } +} + +export class ConversationChangeTracker { + private readonly records = new Map(); + private readonly queues = new Map>(); + + constructor(private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter()) {} + + async beginRun(input: { + conversationId: string; + runId: string; + projectPath: string; + }): Promise { + return await this.lock(input.conversationId, async () => { + const current = this.records.get(input.conversationId); + if (current?.runId === input.runId) return structuredClone(current.snapshot); + const baseline = await this.capture(input.projectPath); + const snapshot: ConversationChangesSnapshot = { + conversationId: input.conversationId, + runId: input.runId, + git: baseline.git, + baselineHead: baseline.head, + files: [], + }; + this.records.set(input.conversationId, { + ...input, + baseline, + touchedPaths: new Set(), + projectRefresh: false, + snapshot, + }); + return structuredClone(snapshot); + }); + } + + async recordTouchedPaths( + conversationId: string, + runId: string, + paths: readonly string[], + refresh = true, + ): Promise { + return await this.lock(conversationId, async () => { + const record = this.requireRun(conversationId, runId); + for (const filePath of paths) record.touchedPaths.add(normalizeRelativePath(filePath)); + if (refresh) record.snapshot = await this.refresh(record, false); + return structuredClone(record.snapshot); + }); + } + + async markProjectRefresh(conversationId: string, runId: string): Promise { + await this.lock(conversationId, async () => { + this.requireRun(conversationId, runId).projectRefresh = true; + }); + } + + async settleRun(conversationId: string, runId: string): Promise { + return await this.lock(conversationId, async () => { + const record = this.records.get(conversationId); + if (!record || record.runId !== runId) return null; + record.snapshot = await this.refresh(record, record.projectRefresh || record.touchedPaths.size === 0); + return structuredClone(record.snapshot); + }); + } + + getSnapshot(conversationId: string): ConversationChangesSnapshot | null { + const snapshot = this.records.get(conversationId)?.snapshot; + return snapshot ? structuredClone(snapshot) : null; + } + + private async capture(projectPath: string): Promise { + const repository = await this.git.run(projectPath, ['rev-parse', '--is-inside-work-tree']); + if (repository.code !== 0 || repository.stdout.trim() !== 'true') { + return { git: false, head: null, files: new Map() }; + } + const [headResult, statusResult] = await Promise.all([ + this.git.run(projectPath, ['rev-parse', 'HEAD']), + this.git.run(projectPath, ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.']), + ]); + if (statusResult.code !== 0) return { git: false, head: null, files: new Map() }; + const entries = parsePorcelainV2(statusResult.stdout); + const files = new Map(); + for (const entry of entries) files.set(entry.path, await this.readState(projectPath, entry)); + return { + git: true, + head: headResult.code === 0 ? headResult.stdout.trim() || null : null, + files, + }; + } + + private async currentStates(projectPath: string): Promise> { + const status = await this.git.run( + projectPath, + ['status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.'], + ); + if (status.code !== 0) return new Map(); + const result = new Map(); + for (const entry of parsePorcelainV2(status.stdout)) { + result.set(entry.path, await this.readState(projectPath, entry)); + } + return result; + } + + private async readState(projectPath: string, entry: StatusEntry): Promise { + if (entry.status === 'untracked') { + return { ...entry, ...await untrackedPreview(projectPath, entry.path) }; + } + const [working, staged] = await Promise.all([ + this.git.run(projectPath, ['diff', '--no-ext-diff', '--no-color', '--relative', '--', entry.path]), + this.git.run(projectPath, ['diff', '--cached', '--no-ext-diff', '--no-color', '--relative', '--', entry.path]), + ]); + const bounded = boundedText(`${staged.stdout}${working.stdout}`, MAX_DIFF_BYTES); + return { + ...entry, + content: bounded.text, + ...(bounded.truncated ? { truncated: true } : {}), + }; + } + + private async refresh(record: ChangeRunRecord, projectWide: boolean): Promise { + if (!record.baseline.git) { + const files: ConversationChangedFile[] = []; + for (const filePath of [...record.touchedPaths].sort().slice(0, MAX_CHANGED_FILES)) { + const current = await untrackedPreview(record.projectPath, filePath); + files.push({ + path: filePath, + status: current.content === 'missing' ? 'deleted' : 'modified', + ...(current.preview !== undefined ? { preview: current.preview } : {}), + ...(current.truncated ? { truncated: true } : {}), + }); + } + return { ...record.snapshot, files }; + } + const current = await this.currentStates(record.projectPath); + const candidates = projectWide + ? [...current.keys(), ...record.baseline.files.keys()] + : [...record.touchedPaths]; + const files: ConversationChangedFile[] = []; + for (const filePath of [...new Set(candidates)].sort().slice(0, MAX_CHANGED_FILES)) { + const next = current.get(filePath); + const baseline = record.baseline.files.get(filePath); + if (!next) { + if (baseline) { + files.push({ + path: filePath, + status: baseline.status === 'untracked' ? 'deleted' : 'modified', + }); + } + continue; + } + if (baseline?.signature === next.signature && baseline.content === next.content) continue; + files.push({ + path: next.path, + status: next.status, + ...(next.status === 'untracked' && next.preview !== undefined ? { preview: next.preview } : {}), + ...(next.status !== 'untracked' && next.content ? { diff: next.content } : {}), + ...(next.truncated ? { truncated: true } : {}), + }); + } + return { ...record.snapshot, files }; + } + + private requireRun(conversationId: string, runId: string): ChangeRunRecord { + const record = this.records.get(conversationId); + if (!record || record.runId !== runId) throw new Error('Conversation change run is stale'); + return record; + } + + private async lock(conversationId: string, operation: () => Promise): Promise { + const previous = this.queues.get(conversationId) ?? Promise.resolve(); + const flight = previous.catch(() => undefined).then(operation); + const tail = flight.then(() => undefined, () => undefined); + this.queues.set(conversationId, tail); + try { + return await flight; + } finally { + if (this.queues.get(conversationId) === tail) this.queues.delete(conversationId); + } + } +} diff --git a/electron/opencode/game-asset-browser.ts b/electron/coding-projects/game-asset-browser.ts similarity index 100% rename from electron/opencode/game-asset-browser.ts rename to electron/coding-projects/game-asset-browser.ts diff --git a/electron/opencode/game-asset-review.ts b/electron/coding-projects/game-asset-review.ts similarity index 100% rename from electron/opencode/game-asset-review.ts rename to electron/coding-projects/game-asset-review.ts diff --git a/electron/coding-projects/skill-registry.ts b/electron/coding-projects/skill-registry.ts new file mode 100644 index 0000000..2ede69f --- /dev/null +++ b/electron/coding-projects/skill-registry.ts @@ -0,0 +1,100 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + BUNDLED_CODING_SKILL_IDS, + type BundledCodingSkillId, +} from '../../shared/coding-skills'; + +export interface ProductCodingSkill { + id: BundledCodingSkillId; + name: string; + description: string; + selected: boolean; +} + +export interface ProductCodingCommand { + name: string; + title: string; + description: string; + source: 'makelore' | 'pi' | 'skill'; + skillId?: BundledCodingSkillId; +} + +const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [ + { name: 'models', title: '切换模型', description: '切换当前会话后续轮次使用的模型', source: 'makelore' }, + { name: 'thinking', title: '思考强度', description: '设置当前会话的思考强度', source: 'makelore' }, + { name: 'compact', title: '压缩会话', description: '使用当前模型总结会话上下文', source: 'makelore' }, + { name: 'fork', title: '分叉会话', description: '从当前用户消息创建新的产品会话', source: 'makelore' }, + { name: 'recover', title: '恢复会话', description: '重新创建当前会话 worker 并恢复状态', source: 'makelore' }, +] as const; + +function frontmatterScalar(content: string, key: string): string | undefined { + const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; + if (!block) return undefined; + const line = block.split(/\r?\n/).find((candidate) => candidate.startsWith(`${key}:`)); + if (!line) return undefined; + const value = line.slice(key.length + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1).trim() || undefined; + } + return value || undefined; +} + +function selectedSkillIds(value: readonly string[]): Set { + const allowed = new Set(BUNDLED_CODING_SKILL_IDS); + const selected = new Set(); + for (const raw of value) { + const id = raw.trim(); + if (!allowed.has(id)) throw new Error(`Unknown bundled coding skill: ${id}`); + selected.add(id as BundledCodingSkillId); + } + return selected; +} + +export async function listProductCodingSkills( + bundledSkillsDir: string, + selectedIds: readonly string[] = [], +): Promise { + const selected = selectedSkillIds(selectedIds); + return await Promise.all(BUNDLED_CODING_SKILL_IDS.map(async (id) => { + const content = await readFile(path.join(bundledSkillsDir, id, 'SKILL.md'), 'utf8'); + return { + id, + name: frontmatterScalar(content, 'name') ?? id, + description: frontmatterScalar(content, 'description') ?? '', + selected: selected.has(id), + }; + })); +} + +export function buildProductCodingCommandCatalog( + skills: readonly ProductCodingSkill[], + piCommands: readonly { name: string; description?: string }[] = [], +): ProductCodingCommand[] { + const commands = [...MAKELORE_COMMANDS]; + const used = new Set(commands.map((command) => command.name)); + for (const command of piCommands) { + const name = command.name.trim(); + if (!name || /\s/u.test(name) || used.has(name)) continue; + used.add(name); + commands.push({ + name, + title: name, + description: command.description?.trim() || 'Pi 命令', + source: 'pi', + }); + } + for (const skill of skills) { + if (!skill.selected || used.has(skill.id)) continue; + used.add(skill.id); + commands.push({ + name: skill.id, + title: skill.name, + description: skill.description || '项目技能', + source: 'skill', + skillId: skill.id, + }); + } + return commands; +} diff --git a/electron/coding-runtime/contracts.ts b/electron/coding-runtime/contracts.ts index aedcad5..53aa135 100644 --- a/electron/coding-runtime/contracts.ts +++ b/electron/coding-runtime/contracts.ts @@ -157,6 +157,40 @@ export interface WriteLeaseDetailsV1 { status: 'waiting' | 'held' | 'released'; } +export interface AgentBrowserDetailsV1 { + schema: 'agent-browser.v1'; + action: 'open' | 'status' | 'close' | 'reset_profile' | 'navigate' | 'send_cdp' | 'read_events' | 'read_payload'; + attachmentId?: string; + mime?: string; +} + +export interface GameAssetsDetailsV1 { + schema: 'game-assets.v1'; + invocationId: string; + candidateIds: string[]; + status: 'pending' | 'resolved'; + pendingAssetIds: string[]; + approvedAssetIds: string[]; + discardedAssetIds: string[]; +} + +export interface RuntimeContextDetailsV1 { + schema: 'runtime-context.v1'; + skills: Array<{ + id: string; + name: string; + description: string; + selected: boolean; + }>; + commands: Array<{ + name: string; + title: string; + description: string; + source: 'makelore' | 'pi' | 'skill'; + skillId?: string; + }>; +} + export interface SubagentDetailsV1 { schema: 'subagent.v1'; dispatchId: string; @@ -176,6 +210,9 @@ export type KnownToolDetails = | ChangedFileDetailsV1 | TaskStateDetailsV1 | WriteLeaseDetailsV1 + | AgentBrowserDetailsV1 + | GameAssetsDetailsV1 + | RuntimeContextDetailsV1 | SubagentDetailsV1; export interface ConversationToolNode { diff --git a/electron/coding-runtime/conversation-reducer.ts b/electron/coding-runtime/conversation-reducer.ts index c64e4e7..b04748c 100644 --- a/electron/coding-runtime/conversation-reducer.ts +++ b/electron/coding-runtime/conversation-reducer.ts @@ -14,6 +14,7 @@ import type { KnownToolDetails, PublicWorkerState, } from './contracts'; +import { productToolDetails } from './product-tool-protocol'; import { projectSubagentDetailsV1 } from './subagent-protocol'; export type ConversationInvalidationCode = @@ -146,21 +147,10 @@ function isContentBlock(value: unknown): value is ConversationContentBlock { function isKnownToolDetails(value: unknown): value is KnownToolDetails { const record = asRecord(value); if (!record || !isNonEmptyString(record.schema)) return false; - if (record.schema === 'changed-file.v1') { - return Array.isArray(record.paths) && record.paths.every(isNonEmptyString); - } + if (productToolDetails(record)) return true; if (record.schema === 'write-lease.v1') { return record.status === 'waiting' || record.status === 'held' || record.status === 'released'; } - if (record.schema === 'task-state.v1') { - return Array.isArray(record.tasks) && record.tasks.every((task) => { - const item = asRecord(task); - return item !== null - && isNonEmptyString(item.id) - && isNonEmptyString(item.title) - && ['pending', 'running', 'complete', 'error'].includes(String(item.status)); - }); - } if (record.schema === 'subagent.v1') { return projectSubagentDetailsV1(record) !== undefined; } diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index 60490f2..ab747ae 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -8,6 +8,10 @@ import type { } from '../contracts'; import type { PiRpcEvent } from './rpc-client'; import { subagentDetailsOfResult } from '../subagent-protocol'; +import { + isProductToolName, + productToolDetailsOfResult, +} from '../product-tool-protocol'; export interface PiEventProjectorOptions { createId(): string; @@ -96,16 +100,19 @@ function projectToolResult( status: ConversationToolNode['status'], value: unknown, ): ConversationPatch[] { - const details = subagentDetailsOfResult(value); + const subagentDetails = subagentDetailsOfResult(value); + const details = subagentDetails ?? productToolDetailsOfResult(value); const result = asRecord(value); - const unknownSubagentDetails = tool.toolName === 'subagent' + const unknownProductDetails = (tool.toolName === 'subagent' || isProductToolName(tool.toolName)) && result?.details !== undefined && !details; - const output = unknownSubagentDetails + const output = unknownProductDetails ? [{ kind: 'text' as const, id: `${tool.id}:output:unavailable`, - text: 'Subagent details are unavailable for this version.', + text: tool.toolName === 'subagent' + ? 'Subagent details are unavailable for this version.' + : 'Tool details are unavailable for this version.', status: 'complete' as const, }] : outputBlocks(tool.id, value); @@ -119,14 +126,14 @@ function projectToolResult( }, }]; const runId = snapshot.run.runId; - if (details && runId) { + if (subagentDetails && runId) { patches.push({ op: 'subagent.upsert', node: { kind: 'subagent', - id: `subagent:${details.dispatchId}`, + id: `subagent:${subagentDetails.dispatchId}`, runId, - details, + details: subagentDetails, }, }); } diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index b354b1f..4110df5 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -13,6 +13,10 @@ import { parsePiSubagentDispatchRequest, type PiSubagentScheduler, } from './subagent'; +import { + type PiProductToolName, + type PiProductTools, +} from './product-tools'; const MAX_REQUEST_BYTES = 64 * 1024; @@ -21,6 +25,8 @@ interface WorkerRegistrationRecord { conversationId: string; generation: number; projectId: string; + projectPath: string | null; + skillIds: string[]; role: 'parent' | 'child'; contextFile: string; runId: string | null; @@ -39,6 +45,8 @@ export interface RegisterPiExtensionWorkerInput { conversationId: string; generation: number; projectId: string; + projectPath?: string; + skillIds?: readonly string[]; extensionsDir: string; role?: 'parent' | 'child'; runId?: string; @@ -62,7 +70,25 @@ interface SubagentBridgeRequest { request: unknown; } -type BridgeRequest = LeaseBridgeRequest | SubagentBridgeRequest; +interface ProductToolBridgeRequest { + action: 'product.invoke'; + conversationId: string; + workerGeneration: number; + runId: string; + resourceId: string; + toolName: PiProductToolName; + input: unknown; +} + +interface ChangeRefreshBridgeRequest { + action: 'changes.bash'; + conversationId: string; + workerGeneration: number; + runId: string; + resourceId: string; +} + +type BridgeRequest = LeaseBridgeRequest | SubagentBridgeRequest | ProductToolBridgeRequest | ChangeRefreshBridgeRequest; export interface PiExtensionSubagentBridge { scheduler: PiSubagentScheduler; @@ -81,6 +107,10 @@ function bridgeRequest(value: unknown): value is BridgeRequest { && typeof value.resourceId === 'string'; if (!common) return false; if (value.action === 'subagent.dispatch') return 'request' in value; + if (value.action === 'product.invoke') { + return typeof value.toolName === 'string' && 'input' in value; + } + if (value.action === 'changes.bash') return true; return (value.action === 'lease.acquire' || value.action === 'lease.release') && (value.leaseId === undefined || typeof value.leaseId === 'string'); } @@ -91,6 +121,7 @@ export class PiManagedExtensionHost { private readonly runBindings = new Map(); private readonly requestFlights = new Set>(); private subagentBridge: PiExtensionSubagentBridge | undefined; + private productTools: PiProductTools | undefined; private server: Server | null = null; private bridgeUrl: string | null = null; private startFlight: Promise | null = null; @@ -104,6 +135,10 @@ export class PiManagedExtensionHost { this.subagentBridge = bridge; } + configureProductTools(productTools: PiProductTools): void { + this.productTools = productTools; + } + async registerWorker(input: RegisterPiExtensionWorkerInput): Promise { if (!Number.isSafeInteger(input.generation) || input.generation <= 0) { throw new Error('Worker generation must be a positive safe integer'); @@ -115,6 +150,9 @@ export class PiManagedExtensionHost { if (role === 'child' && !input.runId?.trim()) { throw new Error('Child extension registration requires a parent run id'); } + if (this.productTools && !input.projectPath?.trim()) { + throw new Error('Product tools require a worker project path'); + } const token = randomBytes(32).toString('base64url'); const contextFile = path.join(input.extensionsDir, `worker-${randomUUID()}.json`); const record: WorkerRegistrationRecord = { @@ -122,6 +160,8 @@ export class PiManagedExtensionHost { conversationId: input.conversationId, generation: input.generation, projectId: input.projectId, + projectPath: input.projectPath?.trim() || null, + skillIds: [...new Set((input.skillIds ?? []).map((id) => id.trim()).filter(Boolean))], role, contextFile, runId: role === 'child' @@ -154,6 +194,9 @@ export class PiManagedExtensionHost { async bindRun(conversationId: string, generation: number, runId: string): Promise { const record = this.findWorker(conversationId, generation); if (!record) throw new Error('Pi extension worker registration is unavailable'); + if (this.productTools && record.projectPath) { + await this.productTools.beginRun({ conversationId, runId, projectPath: record.projectPath }); + } this.releaseWorkerResources(record); this.runBindings.set(conversationId, runId); record.runId = runId; @@ -166,6 +209,9 @@ export class PiManagedExtensionHost { this.runBindings.delete(conversationId); } if (!record || (runId && record.runId !== runId)) return; + if (this.productTools && record.runId) { + await this.productTools.settleRun(conversationId, record.runId).catch(() => null); + } this.releaseWorkerResources(record); record.runId = null; await this.writeContext(record); @@ -249,6 +295,35 @@ export class PiManagedExtensionHost { await this.dispatchSubagents(request, response, record, value); return; } + if (value.action === 'changes.bash') { + if (!this.productTools) { + this.respond(response, 503, { error: 'Conversation change tracker is unavailable' }); + return; + } + await this.productTools.markBash(record.conversationId, value.runId); + this.respond(response, 200, { marked: true }); + return; + } + if (value.action === 'product.invoke') { + if (record.role !== 'parent') { + this.respond(response, 403, { error: 'Child workers cannot invoke parent product tools' }); + return; + } + if (!this.productTools || !record.projectPath) { + this.respond(response, 503, { error: 'Product tools are unavailable' }); + return; + } + const productResult = await this.productTools.execute(value.toolName, { + conversationId: record.conversationId, + runId: value.runId, + resourceId: value.resourceId, + projectId: record.projectId, + projectPath: record.projectPath, + skillIds: record.skillIds, + }, value.input); + this.respond(response, 200, { result: productResult }); + return; + } if (value.action === 'lease.release') { const lease = record.leases.get(value.resourceId); if (!lease || !value.leaseId || lease.id !== value.leaseId) { diff --git a/electron/coding-runtime/pi/extensions/agent-browser.ts b/electron/coding-runtime/pi/extensions/agent-browser.ts new file mode 100644 index 0000000..c05e6de --- /dev/null +++ b/electron/coding-runtime/pi/extensions/agent-browser.ts @@ -0,0 +1,158 @@ +import type { AgentBrowserModule } from '../../../agent-browser'; +import type { AgentBrowserCdpResult, AgentBrowserSnapshot } from '../../../../shared/agent-browser'; +import type { CodingAttachmentStore } from '../../../coding-projects/attachment-store'; +import type { AgentBrowserDetailsV1 } from '../../contracts'; + +export interface AgentBrowserToolContext { + projectId: string; + projectPath: string; +} + +export interface AgentBrowserToolResult { + content: Array<{ type: 'text'; text: string }>; + details: AgentBrowserDetailsV1; +} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Agent browser input is invalid'); + } + return value as Record; +} + +function string(value: unknown, required = false): string | undefined { + const result = typeof value === 'string' ? value.trim() : ''; + if (required && !result) throw new Error('Agent browser input is incomplete'); + return result || undefined; +} + +function integer(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error('Agent browser number is invalid'); + return value as number; +} + +function publicSnapshot(snapshot: AgentBrowserSnapshot): Omit { + const { projectPath: _projectPath, ...safe } = snapshot; + return safe; +} + +function result( + action: AgentBrowserDetailsV1['action'], + value: unknown, + attachment?: { attachmentId: string; mime: string }, +): AgentBrowserToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(value) }], + details: { + schema: 'agent-browser.v1', + action, + ...(attachment ?? {}), + }, + }; +} + +async function readPayloadJson( + browser: AgentBrowserModule, + projectPath: string, + payload: Extract, +): Promise { + let offset = 0; + let data = ''; + while (offset < payload.byteLength) { + const chunk = await browser.readPayload({ projectPath, handle: payload.handle, offset }); + if (chunk.encoding !== 'utf8') throw new Error('Browser screenshot payload encoding is invalid'); + data += chunk.data; + offset = chunk.nextOffset; + if (chunk.done) break; + } + return JSON.parse(data); +} + +export class PiAgentBrowserTool { + constructor( + private readonly browser: AgentBrowserModule, + private readonly attachments: CodingAttachmentStore, + ) {} + + async execute( + context: AgentBrowserToolContext, + input: unknown, + ): Promise { + const body = record(input); + const action = string(body.action, true) as AgentBrowserDetailsV1['action']; + if (action === 'status') { + return result(action, publicSnapshot(await this.browser.getSnapshot(context.projectPath))); + } + if (action === 'open') { + const snapshot = await this.browser.open({ + projectId: context.projectId, + projectPath: context.projectPath, + url: string(body.url, true) as string, + visible: false, + }); + return result(action, publicSnapshot(snapshot)); + } + if (action === 'close') { + return result(action, publicSnapshot(await this.browser.close(context.projectPath))); + } + if (action === 'reset_profile') { + return result(action, publicSnapshot(await this.browser.resetProfile(context.projectPath))); + } + if (action === 'navigate') { + const navigation = string(body.navigation, true); + if (!navigation || !['url', 'back', 'forward', 'reload'].includes(navigation)) { + throw new Error('Agent browser navigation is invalid'); + } + const snapshot = await this.browser.navigate({ + projectPath: context.projectPath, + action: navigation as 'url' | 'back' | 'forward' | 'reload', + ...(body.url === undefined ? {} : { url: string(body.url) }), + }); + return result(action, publicSnapshot(snapshot)); + } + if (action === 'read_events') { + const methods = body.methods === undefined + ? undefined + : Array.isArray(body.methods) && body.methods.every((item) => typeof item === 'string') + ? body.methods.map((item) => item.trim()).filter(Boolean) + : (() => { throw new Error('Agent browser event methods are invalid'); })(); + return result(action, await this.browser.readEvents({ + projectPath: context.projectPath, + after: integer(body.after), + methods, + limit: integer(body.limit), + waitMs: integer(body.waitMs), + })); + } + if (action === 'read_payload') { + return result(action, await this.browser.readPayload({ + projectPath: context.projectPath, + handle: string(body.handle, true) as string, + offset: integer(body.offset), + maxBytes: integer(body.maxBytes), + })); + } + if (action !== 'send_cdp') throw new Error('Agent browser action is invalid'); + const method = string(body.method, true) as string; + const params = body.params === undefined ? undefined : record(body.params); + const cdp = await this.browser.sendCdp({ + projectPath: context.projectPath, + method, + params, + sessionRef: string(body.sessionRef), + timeoutMs: integer(body.timeoutMs), + }); + if (method !== 'Page.captureScreenshot') return result(action, cdp); + const projected = cdp.kind === 'inline' ? cdp.value : await readPayloadJson(this.browser, context.projectPath, cdp); + const data = record(projected).data; + if (typeof data !== 'string' || !data) throw new Error('Browser screenshot result is invalid'); + const format = string(params?.format) ?? 'png'; + const mime = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'; + const attachment = await this.attachments.put(Buffer.from(data, 'base64'), mime); + return result(action, { attachmentId: attachment.attachmentId, mime }, { + attachmentId: attachment.attachmentId, + mime, + }); + } +} diff --git a/electron/coding-runtime/pi/extensions/changed-file.ts b/electron/coding-runtime/pi/extensions/changed-file.ts new file mode 100644 index 0000000..7c536eb --- /dev/null +++ b/electron/coding-runtime/pi/extensions/changed-file.ts @@ -0,0 +1,35 @@ +import type { ConversationChangeTracker } from '../../../coding-projects/conversation-change-tracker'; +import type { ChangedFileDetailsV1 } from '../../contracts'; + +export interface ChangedFileToolContext { + conversationId: string; + runId: string; +} + +export async function reportChangedFiles( + tracker: ConversationChangeTracker, + context: ChangedFileToolContext, + input: unknown, +) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Changed file report is invalid'); + } + const record = input as Record; + const rawPaths = record.paths ?? (record.path === undefined ? undefined : [record.path]); + if (!Array.isArray(rawPaths) || rawPaths.length === 0 || rawPaths.length > 200 + || rawPaths.some((item) => typeof item !== 'string')) { + throw new Error('Changed file report requires relative paths'); + } + const snapshot = await tracker.recordTouchedPaths( + context.conversationId, + context.runId, + rawPaths as string[], + record.refresh !== false, + ); + const paths = snapshot.files.map((file) => file.path); + const details: ChangedFileDetailsV1 = { schema: 'changed-file.v1', paths }; + return { + content: [{ type: 'text' as const, text: JSON.stringify({ paths, changes: snapshot.files }) }], + details, + }; +} diff --git a/electron/coding-runtime/pi/extensions/game-assets.ts b/electron/coding-runtime/pi/extensions/game-assets.ts new file mode 100644 index 0000000..87ebf4f --- /dev/null +++ b/electron/coding-runtime/pi/extensions/game-assets.ts @@ -0,0 +1,85 @@ +import { + loadGameAssetCandidates, + type GameAssetCandidate, +} from '../../../coding-projects/game-asset-browser'; +import { + loadGameAssetReview, + type GameAssetReviewSnapshot, +} from '../../../coding-projects/game-asset-review'; +import type { GameAssetsDetailsV1 } from '../../contracts'; + +export interface GameAssetToolResult { + content: Array<{ type: 'text'; text: string }>; + details: GameAssetsDetailsV1; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function ids(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 200 || value.some((item) => typeof item !== 'string')) { + throw new Error('Game asset candidate ids are invalid'); + } + return [...new Set(value.map((item) => item.trim()).filter(Boolean))]; +} + +function details(review: GameAssetReviewSnapshot): GameAssetsDetailsV1 { + return { + schema: 'game-assets.v1', + invocationId: review.invocationId, + candidateIds: review.candidateIds, + status: review.status, + pendingAssetIds: review.pendingAssetIds, + approvedAssetIds: review.approvedAssetIds, + discardedAssetIds: review.discardedAssetIds, + }; +} + +function safeCandidate(candidate: GameAssetCandidate) { + return { + id: candidate.id, + name: candidate.name, + category: candidate.category, + status: candidate.status, + purpose: candidate.purpose, + source: candidate.source, + license: candidate.license, + mediaKind: candidate.mediaKind, + manifest: candidate.manifest, + ...(candidate.unavailableReason ? { unavailableReason: candidate.unavailableReason } : {}), + }; +} + +export class PiGameAssetTools { + async browse(projectPath: string, input: unknown, fallbackInvocationId: string): Promise { + const body = record(input); + const invocationId = typeof body.invocationId === 'string' && body.invocationId.trim() + ? body.invocationId.trim() + : fallbackInvocationId; + const candidates = await loadGameAssetCandidates(projectPath); + const review = await loadGameAssetReview(projectPath, invocationId, candidates.map(({ id }) => id)); + return { + content: [{ + type: 'text', + text: JSON.stringify({ candidates: candidates.map(safeCandidate), review: details(review) }), + }], + details: details(review), + }; + } + + async review(projectPath: string, input: unknown, fallbackInvocationId: string): Promise { + const body = record(input); + const invocationId = typeof body.invocationId === 'string' && body.invocationId.trim() + ? body.invocationId.trim() + : fallbackInvocationId; + const review = await loadGameAssetReview(projectPath, invocationId, ids(body.candidateIds)); + return { + content: [{ type: 'text', text: JSON.stringify(details(review)) }], + details: details(review), + }; + } +} diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index 48fea0c..3857185 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -1,15 +1,23 @@ import path from 'node:path'; import { atomicWriteText } from '../../../coding-projects/atomic-json'; -export const MAKELORE_PI_EXTENSION_VERSION = 2; +export const MAKELORE_PI_EXTENSION_VERSION = 3; export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`; const BUNDLE_SOURCE = String.raw` import { readFile } from 'node:fs/promises'; +import path from 'node:path'; -const MUTATION_TOOLS = new Set(['bash', 'edit', 'write']); +const MUTATION_TOOLS = new Set([ + 'bash', + 'edit', + 'write', + 'game_asset_browser', + 'game_asset_review', +]); const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent'; const leases = new Map(); +const touchedPaths = new Map(); async function runtimeContext() { const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8')); @@ -84,6 +92,37 @@ async function releaseLease(toolCallId) { async function releaseAll() { await Promise.all([...leases.keys()].map(releaseLease)); + touchedPaths.clear(); +} + +function projectRelativePath(value) { + if (typeof value !== 'string' || !value.trim()) return undefined; + const projectPath = path.resolve(process.cwd()); + const targetPath = path.resolve(projectPath, value); + const relative = path.relative(projectPath, targetPath); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return undefined; + return relative.split(path.sep).join('/'); +} + +async function invokeProduct(toolCallId, toolName, input, signal) { + const response = await bridge('product.invoke', { + resourceId: toolCallId, + toolName, + input, + }, signal); + return response.result; +} + +function registerProductTool(pi, name, label, description, parameters) { + pi.registerTool({ + name, + label, + description, + parameters, + async execute(toolCallId, params, signal) { + return await invokeProduct(toolCallId, name, params, signal); + }, + }); } export default function makeloreRuntime(pi) { @@ -166,8 +205,94 @@ export default function makeloreRuntime(pi) { }, }); + if (WORKER_ROLE === 'parent') registerProductTool( + pi, + 'agent_browser', + 'Agent browser', + 'Open, navigate, inspect, or close the Main-owned Makelore development browser.', + { + type: 'object', additionalProperties: true, required: ['action'], + properties: { + action: { type: 'string', enum: ['open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload'] }, + url: { type: 'string' }, navigation: { type: 'string', enum: ['url', 'back', 'forward', 'reload'] }, + method: { type: 'string' }, params: { type: 'object' }, sessionRef: { type: 'string' }, + timeoutMs: { type: 'number' }, after: { type: 'number' }, methods: { type: 'array', items: { type: 'string' } }, + limit: { type: 'number' }, waitMs: { type: 'number' }, handle: { type: 'string' }, + offset: { type: 'number' }, maxBytes: { type: 'number' }, + }, + }, + ); + if (WORKER_ROLE === 'parent') registerProductTool( + pi, + 'game_asset_browser', + 'Game assets', + 'Load product-owned game asset candidates and their current review state.', + { type: 'object', additionalProperties: false, properties: { invocationId: { type: 'string' } } }, + ); + if (WORKER_ROLE === 'parent') registerProductTool( + pi, + 'game_asset_review', + 'Game asset review', + 'Load one versioned game asset review interaction without encoding decisions in message text.', + { + type: 'object', additionalProperties: false, + properties: { + invocationId: { type: 'string' }, + candidateIds: { type: 'array', maxItems: 200, items: { type: 'string' } }, + }, + }, + ); + if (WORKER_ROLE === 'parent') registerProductTool( + pi, + 'task_state', + 'Task state', + 'Publish versioned task steps and progress to the Makelore product timeline.', + { + type: 'object', additionalProperties: false, required: ['tasks'], + properties: { + tasks: { + type: 'array', minItems: 1, maxItems: 100, + items: { + type: 'object', additionalProperties: false, required: ['id', 'title', 'status'], + properties: { + id: { type: 'string' }, title: { type: 'string' }, + status: { type: 'string', enum: ['pending', 'running', 'complete', 'error'] }, + }, + }, + }, + }, + }, + ); + if (WORKER_ROLE === 'parent') registerProductTool( + pi, + 'changed_file', + 'Changed file', + 'Report project-relative paths touched by managed tools and refresh Conversation changes.', + { + type: 'object', additionalProperties: false, + properties: { + path: { type: 'string' }, paths: { type: 'array', maxItems: 200, items: { type: 'string' } }, + refresh: { type: 'boolean' }, + }, + }, + ); + if (WORKER_ROLE === 'parent') registerProductTool( + pi, + 'runtime_context', + 'Runtime context', + 'Read the safe selected-skill and command catalog for this managed worker.', + { type: 'object', additionalProperties: false, properties: {} }, + ); + pi.on('tool_call', async (event, ctx) => { if (!MUTATION_TOOLS.has(event.toolName)) return; + const input = event.input || event.arguments || event.args || {}; + const touchedPath = (event.toolName === 'write' || event.toolName === 'edit') + ? projectRelativePath(input.path) : undefined; + if (touchedPath) touchedPaths.set(event.toolCallId, [touchedPath]); + if (event.toolName === 'bash') { + await bridge('changes.bash', { resourceId: event.toolCallId }, ctx.signal).catch(() => undefined); + } ctx.ui.setStatus('makelore.write-lease', '等待项目写入'); try { const result = await bridge('lease.acquire', { resourceId: event.toolCallId }, ctx.signal); @@ -176,7 +301,14 @@ export default function makeloreRuntime(pi) { ctx.ui.setStatus('makelore.write-lease', undefined); } }); - pi.on('tool_result', async (event) => releaseLease(event.toolCallId)); + pi.on('tool_result', async (event) => { + await releaseLease(event.toolCallId); + const paths = touchedPaths.get(event.toolCallId); + touchedPaths.delete(event.toolCallId); + if (paths) { + await invokeProduct(event.toolCallId, 'changed_file', { paths, refresh: true }).catch(() => undefined); + } + }); pi.on('agent_end', releaseAll); pi.on('session_shutdown', releaseAll); } diff --git a/electron/coding-runtime/pi/extensions/task-state.ts b/electron/coding-runtime/pi/extensions/task-state.ts new file mode 100644 index 0000000..86eb348 --- /dev/null +++ b/electron/coding-runtime/pi/extensions/task-state.ts @@ -0,0 +1,41 @@ +import type { TaskStateDetailsV1 } from '../../contracts'; + +export interface TaskStateToolResult { + content: Array<{ type: 'text'; text: string }>; + details: TaskStateDetailsV1; +} + +export function projectTaskState(input: unknown): TaskStateToolResult { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Task state is invalid'); + const tasks = (input as { tasks?: unknown }).tasks; + if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > 100) { + throw new Error('Task state requires one to one hundred tasks'); + } + const ids = new Set(); + const projected: TaskStateDetailsV1['tasks'] = tasks.map((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + throw new Error('Task state item is invalid'); + } + const item = candidate as Record; + const id = typeof item.id === 'string' ? item.id.trim() : ''; + const title = typeof item.title === 'string' ? item.title.trim() : ''; + if (!id || id.length > 128 || ids.has(id) || !title || title.length > 500 + || !['pending', 'running', 'complete', 'error'].includes(String(item.status))) { + throw new Error('Task state item is invalid'); + } + ids.add(id); + return { + id, + title, + status: item.status as TaskStateDetailsV1['tasks'][number]['status'], + }; + }); + const details: TaskStateDetailsV1 = { schema: 'task-state.v1', tasks: projected }; + return { + content: [{ + type: 'text', + text: `${projected.filter(({ status }) => status === 'complete').length}/${projected.length} tasks complete`, + }], + details, + }; +} diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts new file mode 100644 index 0000000..4691554 --- /dev/null +++ b/electron/coding-runtime/pi/product-tools.ts @@ -0,0 +1,102 @@ +import type { AgentBrowserModule } from '../../agent-browser'; +import type { CodingAttachmentStore } from '../../coding-projects/attachment-store'; +import { + ConversationChangeTracker, + type ConversationChangesSnapshot, +} from '../../coding-projects/conversation-change-tracker'; +import { + buildProductCodingCommandCatalog, + listProductCodingSkills, +} from '../../coding-projects/skill-registry'; +import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; +import { PiAgentBrowserTool } from './extensions/agent-browser'; +import { reportChangedFiles } from './extensions/changed-file'; +import { PiGameAssetTools } from './extensions/game-assets'; +import { projectTaskState } from './extensions/task-state'; + +export type PiProductToolName = + | 'agent_browser' + | 'game_asset_browser' + | 'game_asset_review' + | 'task_state' + | 'changed_file' + | 'runtime_context'; + +export interface PiProductToolContext { + conversationId: string; + runId: string; + resourceId: string; + projectId: string; + projectPath: string; + skillIds: readonly string[]; +} + +export interface PiProductToolResult { + content: Array<{ type: 'text'; text: string }>; + details: KnownToolDetails; +} + +export interface PiProductToolsOptions { + browser: AgentBrowserModule; + attachments: CodingAttachmentStore; + bundledSkillsDir: string; + changeTracker?: ConversationChangeTracker; +} + +export class PiProductTools { + readonly changeTracker: ConversationChangeTracker; + private readonly browser: PiAgentBrowserTool; + private readonly gameAssets = new PiGameAssetTools(); + + constructor(private readonly options: PiProductToolsOptions) { + this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); + this.browser = new PiAgentBrowserTool(options.browser, options.attachments); + } + + beginRun(input: { conversationId: string; runId: string; projectPath: string }) { + return this.changeTracker.beginRun(input); + } + + settleRun(conversationId: string, runId: string) { + return this.changeTracker.settleRun(conversationId, runId); + } + + getChanges(conversationId: string): ConversationChangesSnapshot | null { + return this.changeTracker.getSnapshot(conversationId); + } + + async markBash(conversationId: string, runId: string): Promise { + await this.changeTracker.markProjectRefresh(conversationId, runId); + } + + async execute( + toolName: PiProductToolName, + context: PiProductToolContext, + input: unknown, + ): Promise { + if (toolName === 'agent_browser') { + return await this.browser.execute(context, input); + } + if (toolName === 'game_asset_browser') { + return await this.gameAssets.browse(context.projectPath, input, context.resourceId); + } + if (toolName === 'game_asset_review') { + return await this.gameAssets.review(context.projectPath, input, context.resourceId); + } + if (toolName === 'task_state') return projectTaskState(input); + if (toolName === 'changed_file') { + return await reportChangedFiles(this.changeTracker, context, input); + } + if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); + const skills = await listProductCodingSkills(this.options.bundledSkillsDir, context.skillIds); + const details: RuntimeContextDetailsV1 = { + schema: 'runtime-context.v1', + skills, + commands: buildProductCodingCommandCatalog(skills), + }; + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + }; + } +} diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 578a80d..8f8d1cd 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -225,6 +225,8 @@ export function createPiManagedWorkerOpener( conversationId: input.conversation.conversationId, generation: input.generation, projectId: input.conversation.projectId, + projectPath: registered.projectPath, + skillIds: registered.agent.skillIds, extensionsDir: managedPaths.extensionsDir, }); recordManagedMilestone( diff --git a/electron/coding-runtime/pi/session-projector.ts b/electron/coding-runtime/pi/session-projector.ts index fc5b2ae..70c611b 100644 --- a/electron/coding-runtime/pi/session-projector.ts +++ b/electron/coding-runtime/pi/session-projector.ts @@ -12,6 +12,7 @@ import type { PiImageProjectionInput, PiProjectedAttachment, } from './event-projector'; +import { isProductToolName, productToolDetailsOfResult } from '../product-tool-protocol'; import { subagentDetailsOfResult } from '../subagent-protocol'; export interface PiSessionSnapshotInput { @@ -213,24 +214,29 @@ async function projectEntries( 'output', ); tool.status = message.isError === true ? 'error' : 'complete'; - const details = subagentDetailsOfResult(message); - if (details) { - tool.details = details; - const id = `subagent:${details.dispatchId}`; + const subagentDetails = subagentDetailsOfResult(message); + const details = subagentDetails ?? productToolDetailsOfResult(message); + if (details) tool.details = details; + if (subagentDetails) { + const id = `subagent:${subagentDetails.dispatchId}`; if (!subagentIds.has(id)) { subagentIds.add(id); nodes.push({ kind: 'subagent', id, runId: input.snapshot.run.runId ?? `session:${entry.id}`, - details, + details: subagentDetails, }); } - } else if (tool.toolName === 'subagent' && message.details !== undefined) { + } else if ((tool.toolName === 'subagent' || isProductToolName(tool.toolName)) + && message.details !== undefined + && !details) { tool.output = [{ kind: 'text', id: `${tool.id}:output:unavailable`, - text: 'Subagent details are unavailable for this version.', + text: tool.toolName === 'subagent' + ? 'Subagent details are unavailable for this version.' + : 'Tool details are unavailable for this version.', status: 'complete', }]; } diff --git a/electron/coding-runtime/pi/subagent-child.ts b/electron/coding-runtime/pi/subagent-child.ts index 8091a4f..de521b7 100644 --- a/electron/coding-runtime/pi/subagent-child.ts +++ b/electron/coding-runtime/pi/subagent-child.ts @@ -194,6 +194,8 @@ export function createPiManagedSubagentChildOpener( conversationId: input.conversationId, generation: input.workerGeneration, projectId: input.projectId, + projectPath: project.path, + skillIds: agent.skillIds, extensionsDir: managedPaths.extensionsDir, role: 'child', runId: input.runId, diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index 4bb52a8..e3ef0fb 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -67,6 +67,8 @@ export function buildPiRpcArgs( additionalArgs: readonly string[] = [], tools: readonly string[] = [ 'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent', + 'agent_browser', 'game_asset_browser', 'game_asset_review', + 'task_state', 'changed_file', 'runtime_context', ], ): string[] { return [ diff --git a/electron/coding-runtime/product-tool-protocol.ts b/electron/coding-runtime/product-tool-protocol.ts new file mode 100644 index 0000000..164e39c --- /dev/null +++ b/electron/coding-runtime/product-tool-protocol.ts @@ -0,0 +1,156 @@ +import type { + AgentBrowserDetailsV1, + ChangedFileDetailsV1, + GameAssetsDetailsV1, + KnownToolDetails, + RuntimeContextDetailsV1, + TaskStateDetailsV1, +} from './contracts'; + +const PRODUCT_TOOL_NAMES = new Set([ + 'agent_browser', + 'game_asset_browser', + 'game_asset_review', + 'task_state', + 'changed_file', + 'runtime_context', +]); + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown, max = 4096): string | null { + return typeof value === 'string' && value.trim() && value.length <= max ? value : null; +} + +function strings(value: unknown, maxItems = 200): string[] | null { + if (!Array.isArray(value) || value.length > maxItems) return null; + const result: string[] = []; + for (const item of value) { + const normalized = text(item); + if (!normalized) return null; + result.push(normalized); + } + return result; +} + +function relativePaths(value: unknown): string[] | null { + const paths = strings(value); + if (!paths) return null; + const result: string[] = []; + for (const candidate of paths) { + const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, ''); + if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) + || normalized.split('/').some((segment) => segment === '..')) return null; + result.push(normalized); + } + return result; +} + +function browserDetails(value: Record): AgentBrowserDetailsV1 | null { + const actions = new Set([ + 'open', 'status', 'close', 'reset_profile', 'navigate', 'send_cdp', 'read_events', 'read_payload', + ]); + if (!actions.has(String(value.action))) return null; + const attachmentId = value.attachmentId === undefined ? undefined : text(value.attachmentId, 128); + const mime = value.mime === undefined ? undefined : text(value.mime, 128); + if ((value.attachmentId !== undefined && !attachmentId) || (value.mime !== undefined && !mime)) return null; + if (Boolean(attachmentId) !== Boolean(mime) || (attachmentId && value.action !== 'send_cdp')) return null; + return { + schema: 'agent-browser.v1', + action: value.action as AgentBrowserDetailsV1['action'], + ...(attachmentId ? { attachmentId } : {}), + ...(mime ? { mime } : {}), + }; +} + +function gameAssetDetails(value: Record): GameAssetsDetailsV1 | null { + const invocationId = text(value.invocationId, 200); + const candidateIds = strings(value.candidateIds); + const pendingAssetIds = strings(value.pendingAssetIds); + const approvedAssetIds = strings(value.approvedAssetIds); + const discardedAssetIds = strings(value.discardedAssetIds); + if (!invocationId || !candidateIds || !pendingAssetIds || !approvedAssetIds || !discardedAssetIds) return null; + if (value.status !== 'pending' && value.status !== 'resolved') return null; + return { + schema: 'game-assets.v1', + invocationId, + candidateIds, + status: value.status, + pendingAssetIds, + approvedAssetIds, + discardedAssetIds, + }; +} + +function taskDetails(value: Record): TaskStateDetailsV1 | null { + if (!Array.isArray(value.tasks) || value.tasks.length === 0 || value.tasks.length > 100) return null; + const tasks: TaskStateDetailsV1['tasks'] = []; + for (const candidate of value.tasks) { + const item = record(candidate); + const id = text(item?.id, 128); + const title = text(item?.title, 500); + if (!item || !id || !title || !['pending', 'running', 'complete', 'error'].includes(String(item.status))) { + return null; + } + tasks.push({ id, title, status: item.status as TaskStateDetailsV1['tasks'][number]['status'] }); + } + return { schema: 'task-state.v1', tasks }; +} + +function runtimeContextDetails(value: Record): RuntimeContextDetailsV1 | null { + if (!Array.isArray(value.skills) || value.skills.length > 100 + || !Array.isArray(value.commands) || value.commands.length > 200) return null; + const skills: RuntimeContextDetailsV1['skills'] = []; + const commands: RuntimeContextDetailsV1['commands'] = []; + for (const candidate of value.skills) { + const item = record(candidate); + const id = text(item?.id, 128); + const name = text(item?.name, 200); + if (!item || !id || !name || typeof item.description !== 'string' || item.description.length > 2000 + || typeof item.selected !== 'boolean') return null; + skills.push({ id, name, description: item.description, selected: item.selected }); + } + for (const candidate of value.commands) { + const item = record(candidate); + const name = text(item?.name, 128); + const title = text(item?.title, 200); + if (!item || !name || !title || typeof item.description !== 'string' || item.description.length > 2000 + || !['makelore', 'pi', 'skill'].includes(String(item.source))) return null; + const skillId = item.skillId === undefined ? undefined : text(item.skillId, 128); + if (item.skillId !== undefined && !skillId) return null; + commands.push({ + name, + title, + description: item.description, + source: item.source as RuntimeContextDetailsV1['commands'][number]['source'], + ...(skillId ? { skillId } : {}), + }); + } + return { schema: 'runtime-context.v1', skills, commands }; +} + +export function productToolDetails(value: unknown): Exclude | null { + const details = record(value); + if (!details) return null; + if (details.schema === 'changed-file.v1') { + const paths = relativePaths(details.paths); + return paths ? { schema: 'changed-file.v1', paths } satisfies ChangedFileDetailsV1 : null; + } + if (details.schema === 'task-state.v1') return taskDetails(details); + if (details.schema === 'agent-browser.v1') return browserDetails(details); + if (details.schema === 'game-assets.v1') return gameAssetDetails(details); + if (details.schema === 'runtime-context.v1') return runtimeContextDetails(details); + return null; +} + +export function productToolDetailsOfResult(value: unknown) { + return productToolDetails(record(value)?.details); +} + +export function isProductToolName(value: string): boolean { + return PRODUCT_TOOL_NAMES.has(value); +} diff --git a/resources/coding-skills/agent-browser/SKILL.md b/resources/coding-skills/agent-browser/SKILL.md index 773f83d..f4e6bf5 100644 --- a/resources/coding-skills/agent-browser/SKILL.md +++ b/resources/coding-skills/agent-browser/SKILL.md @@ -10,19 +10,18 @@ description: 当需要打开、查看或调试当前 Makelore 项目的本地或 ## 基本流程 1. 确认项目的开发服务已经启动,并取得本地网页地址。 -2. 调用 `browser_context` 的 `open` 打开该地址。 -3. 调用 `browser_cdp` 的 `read_events` 读取 Console 与 Network 事件。 -4. 需要主动检查页面时,使用 `browser_cdp` 的 `send` 调用标准 CDP 方法。 -5. 修改代码后调用 `browser_navigate` 的 `reload`,再读取新事件验证结果。 +2. 调用 `agent_browser` 的 `open` 打开该地址。 +3. 调用 `agent_browser` 的 `read_events` 读取 Console 与 Network 事件。 +4. 需要主动检查页面时,使用 `agent_browser` 的 `send_cdp` 调用标准 CDP 方法。 +5. 修改代码后调用 `agent_browser` 的 `navigate` + `reload`,再读取新事件验证结果。 ## 工具选择 -- `browser_context`:打开、查看状态、关闭浏览器或清空当前项目的浏览器数据。 -- `browser_navigate`:跳转、后退、前进或刷新共享页面。 -- `browser_cdp`: - - `send` 原样执行当前项目页面的 CDP 命令; - - `read_events` 按游标读取原始事件,不会消费其他读取者的数据; - - `read_payload` 分块读取响应体、截图等大型结果。 +- `agent_browser`:以 `action` 选择 `open`、`status`、`close`、 + `reset_profile`、`navigate`、`send_cdp`、`read_events` 或 `read_payload`。 +- `send_cdp` 原样执行当前项目页面的 CDP 命令;`read_events` 按游标读取 + 原始事件且不会消费其他读取者的数据;`read_payload` 分块读取大型结果。 +- 截图由 Main 保存并只返回 `attachmentId`,不要在会话中重复传递 base64。 ## 约束 diff --git a/tests/unit/game-asset-browser.test.ts b/tests/unit/game-asset-browser.test.ts index fe91de5..32726f0 100644 --- a/tests/unit/game-asset-browser.test.ts +++ b/tests/unit/game-asset-browser.test.ts @@ -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[] = []; diff --git a/tests/unit/game-asset-review.test.ts b/tests/unit/game-asset-review.test.ts index cc10c04..d6ff877 100644 --- a/tests/unit/game-asset-review.test.ts +++ b/tests/unit/game-asset-review.test.ts @@ -7,7 +7,7 @@ import { loadGameAssetReview, recordGameAssetReviewActions, recordGameAssetReviewAction, -} from '@electron/opencode/game-asset-review'; +} from '@electron/coding-projects/game-asset-review'; const temporaryDirectories: string[] = []; diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts index 5a0d04f..9366bc5 100644 --- a/tests/unit/pi-event-projector.test.ts +++ b/tests/unit/pi-event-projector.test.ts @@ -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', diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts index 87d1f8d..5cfb9c3 100644 --- a/tests/unit/pi-extension-bundle.test.ts +++ b/tests/unit/pi-extension-bundle.test.ts @@ -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; 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(); + const handlers = new Map(); + 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?.( diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts index c6d11f5..1111e74 100644 --- a/tests/unit/pi-managed-worker-opener.test.ts +++ b/tests/unit/pi-managed-worker-opener.test.ts @@ -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'); diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts new file mode 100644 index 0000000..c9a11cc --- /dev/null +++ b/tests/unit/pi-product-tools.test.ts @@ -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 { + 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 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:'); + }); +}); diff --git a/tests/unit/pi-rpc-foundation.test.ts b/tests/unit/pi-rpc-foundation.test.ts index 733427e..1d8656b 100644 --- a/tests/unit/pi-rpc-foundation.test.ts +++ b/tests/unit/pi-rpc-foundation.test.ts @@ -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'])) diff --git a/tests/unit/pi-session-projector.test.ts b/tests/unit/pi-session-projector.test.ts index 46244fc..31703d1 100644 --- a/tests/unit/pi-session-projector.test.ts +++ b/tests/unit/pi-session-projector.test.ts @@ -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(), diff --git a/tests/unit/pi-worker-process-real.test.ts b/tests/unit/pi-worker-process-real.test.ts index d28320d..0867be9 100644 --- a/tests/unit/pi-worker-process-real.test.ts +++ b/tests/unit/pi-worker-process-real.test.ts @@ -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, + ); });