From 3d1a9c618f0416e6e1e794608435ed5ef10bbf54 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:34:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(pi):=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E5=B1=95=E7=A4=BA=E6=8C=89=20harness=20=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96,=E4=B8=8D=E5=86=8D=E5=8F=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=A3=B8=E5=B7=A5=E5=85=B7=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PI 内置工具名全小写(bash/read/edit/write/grep/find/ls)、文件参数字段为 path,而展示层的 describeToolUse 只认 Claude Code 大写名与 Codex 名,PI 的调用一律落进 generic 分支 —— 行级动词兜底成「调用」,参数位只剩裸工具 名,命令原文、文件名、搜索模式全部看不见。 把 PI 内置工具接进同一条展示产线: - describeToolUse 补小写分支(bash→command 走 intent 解析,read/ls→file read,edit/write→file edit/create,grep/find→search);不用无脑 toLowerCase 统一,因为 CC 的 Bash 带模型写的 description 而 PI 无此 字段,展示策略不同。 - 行级动词映射补小写条目,「调用」变回「运行 / 读取 / 编辑 / 搜索」。 - diff 统计与 diff lightbox 认 PI 的 edits[].oldText/newText 与 write.content,PI 编辑行恢复 +N -N 与 diff 预览。 - 展开详情、文件 chip 与 Read lightbox、最近一轮改动路径、mobile payload diff 同步认小写名与 path 字段。 灵动岛(agent-island/toolDetail)与 mobile 消息投影本就由 describeToolUse 驱动,随本次修复一并受益。 Signed-off-by: Chris Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/agentActionRowRendering.test.ts | 81 +++++++++++++++++++ .../src/renderer/__tests__/diffStats.test.ts | 24 ++++++ .../renderer/__tests__/verbAggregator.test.ts | 10 +++ .../components/chat/AgentActionRow.tsx | 38 +++++++-- .../right-sidebar/lib/lastTurnChangedFiles.ts | 5 +- .../renderer/lib/agent-actions/diffStats.ts | 18 ++++- .../lib/agent-actions/verbAggregator.ts | 8 ++ .../src/__tests__/toolUseDescriptor.test.ts | 62 ++++++++++++++ packages/maker-shared/src/payloadSummary.ts | 23 +++++- .../maker-shared/src/toolUseDescriptor.ts | 24 +++++- 10 files changed, 280 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts b/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts index cf1447ce377..b2e30cd740a 100644 --- a/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts +++ b/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts @@ -221,6 +221,87 @@ describe('AgentActionRow — 行主文案', () => { expect(document.querySelector('[data-agent-action-file-chip="true"]')).toBeTruthy(); }); + it('pi bash:小写工具名照样解析出意图动词,不再是「调用 bash」', () => { + render( + createElement(AgentActionRow, { + message: mkTool('t1', 'bash', { command: 'git status' }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.gitStatus')).toBeTruthy(); + expect(screen.queryByText('chat.agentActionRow.verb.used')).toBeNull(); + expect(screen.queryByText('bash')).toBeNull(); + }); + + it('pi bash:无法分类的命令回退为运行动词 + 命令原文', () => { + render( + createElement(AgentActionRow, { + message: mkTool('t1', 'bash', { command: 'docker ps' }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.ran')).toBeTruthy(); + expect(screen.getByText('docker ps')).toBeTruthy(); + }); + + it('pi read:path 字段渲染成文件 chip 与读取动词', () => { + render( + createElement(AgentActionRow, { + message: mkTool('t1', 'read', { path: '/repo/src/app.ts' }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.read')).toBeTruthy(); + expect(screen.getByText('app.ts')).toBeTruthy(); + expect(document.querySelector('[data-agent-action-file-chip="true"]')).toBeTruthy(); + }); + + it('pi grep / find:搜索动词 + 搜索目标', () => { + const { rerender } = render( + createElement(AgentActionRow, { + message: mkTool('t1', 'grep', { pattern: 'TODO', path: 'src/' }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.searched')).toBeTruthy(); + expect(screen.getByText('TODO')).toBeTruthy(); + rerender( + createElement(AgentActionRow, { + message: mkTool('t1', 'find', { pattern: '**/*.spec.ts' }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.searched')).toBeTruthy(); + expect(screen.getByText('**/*.spec.ts')).toBeTruthy(); + }); + + it('pi write:创建动词 + 行内 +N 统计,点击进共享 diff lightbox', () => { + render( + createElement(AgentActionRow, { + message: mkTool('t1', 'write', { path: '/repo/src/new.ts', content: 'a\nb' }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.created')).toBeTruthy(); + expect(screen.getByText('new.ts')).toBeTruthy(); + expect(screen.getByText('+2')).toBeTruthy(); + fireEvent.click(screen.getByRole('button')); + expect(document.body.textContent).toContain('"kind":"diff"'); + expect(document.body.textContent).toContain('"filePath":"/repo/src/new.ts"'); + }); + + it('pi edit:edits[].oldText/newText 汇成编辑动词与 diff lightbox', () => { + render( + createElement(AgentActionRow, { + message: mkTool('t1', 'edit', { + path: '/repo/src/app.ts', + edits: [{ oldText: 'old', newText: 'new' }], + }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.edited')).toBeTruthy(); + expect(screen.getByText('app.ts')).toBeTruthy(); + expect(screen.getByText('+1')).toBeTruthy(); + expect(screen.getByText('-1')).toBeTruthy(); + fireEvent.click(screen.getByRole('button')); + expect(document.body.textContent).toContain('"oldString":"old"'); + expect(document.body.textContent).toContain('"newString":"new"'); + }); + it('状态图标:running / done 经 aria-label 可达,缺省为 done', () => { const { rerender } = render( createElement(AgentActionRow, { diff --git a/apps/desktop/src/renderer/__tests__/diffStats.test.ts b/apps/desktop/src/renderer/__tests__/diffStats.test.ts index f0d6bfa4246..26a6bed7f0e 100644 --- a/apps/desktop/src/renderer/__tests__/diffStats.test.ts +++ b/apps/desktop/src/renderer/__tests__/diffStats.test.ts @@ -104,6 +104,30 @@ describe('statsForToolCall', () => { expect(statsForToolCall('MultiEdit', { edits: [] })).toEqual({ add: 0, del: 0 }); }); + it('pi edit: sums edits[].oldText/newText like MultiEdit', () => { + const stats = statsForToolCall('edit', { + path: '/foo', + edits: [ + { oldText: 'a', newText: 'b' }, // +1 -1 + { oldText: '', newText: 'x\ny' }, // +2 -0 + ], + }); + expect(stats).toEqual({ add: 3, del: 1 }); + }); + + it('pi write: full content as +N -0', () => { + expect(statsForToolCall('write', { path: '/foo', content: 'a\nb' })).toEqual({ + add: 2, + del: 0, + }); + }); + + it('pi read-only tools stay null', () => { + expect(statsForToolCall('read', { path: '/foo' })).toBeNull(); + expect(statsForToolCall('bash', { command: 'ls' })).toBeNull(); + expect(statsForToolCall('grep', { pattern: 'foo' })).toBeNull(); + }); + it('file_change: sums unified diffs across all changed files', () => { expect(statsForToolCall('file_change', { changes: [ diff --git a/apps/desktop/src/renderer/__tests__/verbAggregator.test.ts b/apps/desktop/src/renderer/__tests__/verbAggregator.test.ts index 726e9fd3c00..2b667c9a183 100644 --- a/apps/desktop/src/renderer/__tests__/verbAggregator.test.ts +++ b/apps/desktop/src/renderer/__tests__/verbAggregator.test.ts @@ -71,6 +71,16 @@ describe('verbForTool', () => { expect(verbForTool('web_search')).toBe('fetched'); }); + it('maps pi builtin lowercase tools', () => { + expect(verbForTool('bash')).toBe('ran'); + expect(verbForTool('read')).toBe('read'); + expect(verbForTool('ls')).toBe('read'); + expect(verbForTool('edit')).toBe('edited'); + expect(verbForTool('write')).toBe('created'); + expect(verbForTool('grep')).toBe('searched'); + expect(verbForTool('find')).toBe('searched'); + }); + it('falls back to "used" for unknown tools', () => { expect(verbForTool('FooBar')).toBe('used'); expect(verbForTool('')).toBe('used'); diff --git a/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx b/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx index 8f21896920a..2cc89f8e3fa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx @@ -69,7 +69,8 @@ import { TextLightbox } from './TextLightbox'; import { ToolPayloadLightbox, type ToolPayloadMode } from './ToolPayloadLightbox'; import { useFileChipContextMenu } from './useFileChipContextMenu'; -const FILE_PATH_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Read']); +// CC 大写 + pi 小写(pi 内置工具名全小写、文件字段为 path,见 toolUseDescriptor.ts)。 +const FILE_PATH_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Read', 'edit', 'write', 'read']); /** * v10 (2026-04-20): 命令类工具(Bash/Grep/Glob/WebFetch/WebSearch/...)的 @@ -451,6 +452,7 @@ function formatInlineInput( if (!inp) return ''; switch (toolName) { case 'Bash': + case 'bash': case 'exec': { // description 已上移为行主文案(issue #450),这里只展示命令原文 + cwd, // 避免同一句话在折叠行和展开区重复出现。 @@ -458,7 +460,8 @@ function formatInlineInput( const cwd = typeof inp.cwd === 'string' && inp.cwd ? `cwd: ${inp.cwd}` : ''; return cwd ? `${cmd}\n${cwd}` : cmd; } - case 'Grep': { + case 'Grep': + case 'grep': { const pattern = typeof inp.pattern === 'string' ? inp.pattern : ''; const path = typeof inp.path === 'string' ? inp.path : ''; const glob = typeof inp.glob === 'string' ? inp.glob : ''; @@ -474,11 +477,17 @@ function formatInlineInput( .filter(Boolean) .join('\n'); } - case 'Glob': { + case 'Glob': + case 'find': { const pattern = typeof inp.pattern === 'string' ? inp.pattern : ''; const path = typeof inp.path === 'string' ? inp.path : ''; return path ? `${pattern}\nin: ${path}` : pattern; } + case 'ls': { + // pi ls:path 可缺省(默认当前目录)。 + const path = typeof inp.path === 'string' ? inp.path : ''; + return path; + } case 'WebFetch': { const url = typeof inp.url === 'string' ? inp.url : ''; const prompt = typeof inp.prompt === 'string' ? inp.prompt : ''; @@ -536,7 +545,7 @@ function buildDiffPayload( ], }; } - if (toolName === 'Write') { + if (toolName === 'Write' || toolName === 'write') { const c = typeof inp.content === 'string' ? inp.content : ''; return { kind: 'diff', @@ -549,6 +558,25 @@ function buildDiffPayload( ], }; } + // pi edit:edits[].oldText/newText,与 MultiEdit 同款多段 diff 呈现。 + if (toolName === 'edit') { + const edits = Array.isArray(inp.edits) ? inp.edits : []; + return { + kind: 'diff', + files: [ + { + key: filePath, + filePath, + diffs: edits.map((e, index) => { + const er = e as Record | null; + const o = er && typeof er.oldText === 'string' ? er.oldText : ''; + const n = er && typeof er.newText === 'string' ? er.newText : ''; + return { key: `edit:${index}`, oldString: String(o), newString: String(n) }; + }), + }, + ], + }; + } if (toolName === 'MultiEdit') { const edits = Array.isArray(inp.edits) ? inp.edits : []; return { @@ -719,7 +747,7 @@ export function AgentActionRow({ return; } triggerRef.current = anchor; - if (toolName === 'Read' && filePath) { + if ((toolName === 'Read' || toolName === 'read') && filePath) { // 模型可能给相对路径(runtime 按会话工作目录解析后 Read 照样成功),而 // 预览 / 定位 IPC 一律要求绝对路径 —— 先按 workingDir 补齐,镜像 runtime // 语义,保证 chip 打开的就是 agent 实际读到的那个文件。 diff --git a/apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts b/apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts index 99908e0b14d..61822de7b67 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts @@ -7,7 +7,8 @@ import type { ChatMessage } from '@/lib/makerChatStore'; -const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); +// CC 大写(file_path 字段)+ pi 小写(path 字段,见 toolUseDescriptor.ts 数据来源约定)。 +const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'edit', 'write']); function slashPath(p: string): string { return p.replace(/\\/g, '/').replace(/\/+/g, '/'); @@ -75,7 +76,7 @@ function extractToolFilePaths(msg: ChatMessage): string[] { const input = (msg.toolInput as Record | null) ?? null; if (msg.toolName === 'file_change') return collectCodexFileChangePaths(input); if (!EDIT_TOOL_NAMES.has(msg.toolName)) return []; - const filePath = input?.file_path; + const filePath = input?.file_path ?? input?.path; return typeof filePath === 'string' && filePath ? [filePath] : []; } diff --git a/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts b/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts index 83cf7b75250..04e5473608f 100644 --- a/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts +++ b/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts @@ -71,12 +71,28 @@ export function statsForToolCall( return computeDiffStats(o, n); } - if (toolName === 'Write') { + if (toolName === 'Write' || toolName === 'write') { const c = typeof inp.content === 'string' ? inp.content : ''; // All-add: oldStr = ''. Surface as `+N -0` per ADR-5. return computeDiffStats('', c); } + // pi edit:edits[].oldText/newText,逐条求和(与 MultiEdit 同形态)。 + if (toolName === 'edit') { + const edits = Array.isArray(inp.edits) ? inp.edits : []; + let add = 0; + let del = 0; + for (const e of edits) { + const er = e as Record | null; + const o = er && typeof er.oldText === 'string' ? er.oldText : ''; + const n = er && typeof er.newText === 'string' ? er.newText : ''; + const s = computeDiffStats(o, n); + add += s.add; + del += s.del; + } + return { add, del }; + } + if (toolName === 'MultiEdit') { const edits = Array.isArray(inp.edits) ? inp.edits : []; let add = 0; diff --git a/apps/desktop/src/renderer/lib/agent-actions/verbAggregator.ts b/apps/desktop/src/renderer/lib/agent-actions/verbAggregator.ts index 5d3c8a94c08..0ae220df2bf 100644 --- a/apps/desktop/src/renderer/lib/agent-actions/verbAggregator.ts +++ b/apps/desktop/src/renderer/lib/agent-actions/verbAggregator.ts @@ -71,6 +71,14 @@ const TOOL_TO_VERB: Record = { WebFetch: 'fetched', WebSearch: 'fetched', web_search: 'fetched', + // pi 内置工具(全小写,见 toolUseDescriptor.ts 数据来源约定)。 + bash: 'ran', + read: 'read', + ls: 'read', + edit: 'edited', + write: 'created', + grep: 'searched', + find: 'searched', }; const ORDER: Verb[] = [ diff --git a/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts b/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts index 1f1a26ceb74..9943a08c7c6 100644 --- a/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts +++ b/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts @@ -184,6 +184,68 @@ describe('describeToolUse — file tools', () => { }); }); +describe('describeToolUse — pi builtin tools (lowercase, path field)', () => { + it('maps pi bash to command with local intent (schema has no description field)', () => { + expect(describeToolUse('bash', { command: 'git status' })).toEqual({ + kind: 'command', + toolName: 'bash', + command: 'git status', + intent: { action: 'gitStatus' }, + }); + expect(describeToolUse('bash', { command: 'docker ps' })).toEqual({ + kind: 'command', + toolName: 'bash', + command: 'docker ps', + }); + }); + + it('maps pi read/edit/write/ls to file actions via the path field', () => { + expect(describeToolUse('read', { path: '/repo/src/app.ts' })).toEqual({ + kind: 'file', + toolName: 'read', + action: 'read', + filePath: '/repo/src/app.ts', + fileName: 'app.ts', + }); + expect(describeToolUse('edit', { + path: '/repo/a.ts', + edits: [{ oldText: 'a', newText: 'b' }], + })).toMatchObject({ kind: 'file', action: 'edit', fileName: 'a.ts' }); + expect(describeToolUse('write', { path: '/repo/new.ts', content: 'x' })).toMatchObject({ + kind: 'file', + action: 'create', + fileName: 'new.ts', + }); + expect(describeToolUse('ls', { path: '/repo/src' })).toMatchObject({ + kind: 'file', + action: 'read', + fileName: 'src', + }); + }); + + it('degrades pi ls without path (defaults to cwd) to generic', () => { + expect(describeToolUse('ls', {})).toEqual({ kind: 'generic', toolName: 'ls' }); + }); + + it('maps pi grep to grep-mode and pi find (glob pattern) to glob-mode search', () => { + expect(describeToolUse('grep', { pattern: 'TODO', path: 'src/', glob: '*.ts' })).toEqual({ + kind: 'search', + toolName: 'grep', + mode: 'grep', + pattern: 'TODO', + path: 'src/', + glob: '*.ts', + }); + expect(describeToolUse('find', { pattern: '**/*.spec.ts' })).toEqual({ + kind: 'search', + toolName: 'find', + mode: 'glob', + pattern: '**/*.spec.ts', + }); + expect(describeToolUse('grep', {})).toEqual({ kind: 'generic', toolName: 'grep' }); + }); +}); + describe('describeToolUse — Codex file_change', () => { it('normalizes add/update/delete and rename changes', () => { expect(describeToolUse('file_change', { diff --git a/packages/maker-shared/src/payloadSummary.ts b/packages/maker-shared/src/payloadSummary.ts index a0f9e585a2e..99a00e08858 100644 --- a/packages/maker-shared/src/payloadSummary.ts +++ b/packages/maker-shared/src/payloadSummary.ts @@ -216,6 +216,14 @@ export function formatPayloadToolUseSummary(toolName: string, input: unknown): s Bash: ['command'], Glob: ['pattern'], Grep: ['pattern'], + // pi 内置工具:名字全小写、文件参数为 path(见 toolUseDescriptor.ts 数据来源约定)。 + read: ['path'], + edit: ['path'], + write: ['path'], + ls: ['path'], + bash: ['command'], + grep: ['pattern'], + find: ['pattern'], }; const keys = keyParamMap[toolName]; if (!keys) return `${toolName}()`; @@ -240,10 +248,23 @@ export function buildPayloadToolDiff(toolName: string, input: unknown): PayloadT const newString = typeof inp.new_string === 'string' ? inp.new_string : ''; return createPayloadToolDiff(filePath, [{ key: 'edit:0', oldString, newString }]); } - if (toolName === 'Write') { + if (toolName === 'Write' || toolName === 'write') { const newString = typeof inp.content === 'string' ? inp.content : ''; return createPayloadToolDiff(filePath, [{ key: 'write:0', oldString: '', newString }]); } + // pi edit:edits[].oldText/newText(与 MultiEdit 同款多段 diff)。 + if (toolName === 'edit') { + const edits = Array.isArray(inp.edits) ? inp.edits : []; + return createPayloadToolDiff(filePath, edits.map((edit, index) => { + const record = readPayloadRecord(edit); + return { + key: `edit:${index}`, + oldString: typeof record?.oldText === 'string' ? record.oldText : '', + newString: typeof record?.newText === 'string' ? record.newText : '', + label: `Edit ${index + 1}/${edits.length}`, + }; + })); + } if (toolName === 'MultiEdit') { const edits = Array.isArray(inp.edits) ? inp.edits : []; return createPayloadToolDiff(filePath, edits.map((edit, index) => { diff --git a/packages/maker-shared/src/toolUseDescriptor.ts b/packages/maker-shared/src/toolUseDescriptor.ts index e0afca37cae..bb2cc335e94 100644 --- a/packages/maker-shared/src/toolUseDescriptor.ts +++ b/packages/maker-shared/src/toolUseDescriptor.ts @@ -19,6 +19,9 @@ import { * - Codex:shell 工具 toolName='exec'(input 无 description,`displayCommand` * 是解包 POSIX / PowerShell wrapper 后的展示命令);MCP 为 `mcp:server:tool`,另有 * `dynamic:ns:tool` / `collab:tool` / `web_search`。 + * - pi:内置工具名全小写(bash/read/edit/write/grep/find/ls),文件参数字段为 + * `path`(fileDescriptor 已双认 file_path/path);bash 无 description 字段, + * 桥接 MCP 复用 Claude Code 的 `mcp__server__tool` 形态。 */ // ── 工具名拆解 ─────────────────────────────────────────────────────────────── @@ -221,10 +224,13 @@ export function describeToolUse(toolName: string, input: unknown): ToolUseDescri } switch (toolName) { - case 'Bash': { + // pi 内置 bash 与 Claude Code Bash 同构:input.command 必有,description + // 仅 CC 会填(pi schema 无此字段,自然走 intent 兜底),共用一条路径。 + case 'Bash': + case 'bash': { const description = readNonEmptyString(inp?.description); const command = readNonEmptyString(inp?.command) ?? ''; - // description 缺失(模型漏填 / codex exec 无此字段)才兜底算 intent。 + // description 缺失(模型漏填 / pi bash 无此字段)才兜底算 intent。 const intent = description ? undefined : commandIntentFromCommand(command); return { kind: 'command', @@ -251,14 +257,24 @@ export function describeToolUse(toolName: string, input: unknown): ToolUseDescri case 'file_change': return fileChangeDescriptor(toolName, inp); case 'Read': + case 'read': + // pi ls 目标是目录,读取语义与 read 同档;path 可缺省(默认当前目录), + // 缺省时 fileDescriptor 自然降级 generic。 + case 'ls': return fileDescriptor(toolName, 'read', inp); case 'Edit': case 'MultiEdit': + case 'edit': return fileDescriptor(toolName, 'edit', inp); case 'Write': + case 'write': return fileDescriptor(toolName, 'create', inp); + // pi grep/find 与 CC Grep/Glob 同构:pattern 必有,path/glob 可选; + // find 的 pattern 是 glob 表达式,归 glob 模式。 case 'Grep': - case 'Glob': { + case 'Glob': + case 'grep': + case 'find': { const pattern = readNonEmptyString(inp?.pattern); if (!pattern) return genericDescriptor(toolName, inp); const path = readNonEmptyString(inp?.path); @@ -266,7 +282,7 @@ export function describeToolUse(toolName: string, input: unknown): ToolUseDescri return { kind: 'search', toolName, - mode: toolName === 'Grep' ? 'grep' : 'glob', + mode: toolName === 'Grep' || toolName === 'grep' ? 'grep' : 'glob', pattern, ...(path ? { path } : {}), ...(glob ? { glob } : {}), From 6b9171cb13028c4b0353fd01ddacb6eb5fc240ae Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:28:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20pi=20?= =?UTF-8?q?edit=20=E4=B8=A4=E7=A7=8D=E5=85=A5=E5=8F=82=E5=BD=A2=E6=80=81?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96,=E6=BE=84=E6=B8=85=20FILE=5FPATH=5F?= =?UTF-8?q?TOOLS=20=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2:pi edit 的 diff 只按 edits[] 解析,真实事件会拿到空 diff 与 +0 -0。 核对 pi v0.83.0 的 core/tools/edit.ts 后确认它有**两种**入参形态:声明 schema 是 { path, edits: [{oldText,newText}] },同时 LegacyEditToolInput 仍接受顶层 { path, oldText, newText } 并由 normalizeEditInput 归一化。只认任一种都会让 另一种退化成空 diff,因此改为两种都认。 - maker-shared 新增共享归一化器 piEditReplacements:先取 edits[],再把顶层 oldText/newText(两侧都是字符串才认)作为最后一段追加,顺序与 pi 的 normalizeEditInput 一致;单段内只有一侧是字符串时另一侧按空串,纯增/纯删 不会被丢掉。 - AgentActionRow(diff lightbox)、diffStats(行内 +N -N)、payloadSummary (mobile payload)三处消费端改为共用它,消除各自解析导致的漂移。 - 测试补齐真实形态:两种入参各自的 diff/统计/渲染断言,含顶层形态必须给出 非空 diff(断死不出现 "diffs":[])与真实 +1 -2。 Copilot:补 FILE_PATH_TOOLS 注释说明它不是「所有 kind='file' 描述符」的集合 —— pi 的 ls 归一化成 kind='file' 并渲染文件 chip,但刻意不入列(目标是目录, 开 lightbox 无意义),点击仍走命令类就地展开;新增工具按「点击后该看到什么」 判断是否入列。 同时合入最新 upstream/main(带上 #1440 的 VoiceInputSection CRLF 断言修复), 本 PR 的 Windows CI 红是该基线问题所致(issue #1448),非本 PR 引入。 Signed-off-by: Chris Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/agentActionRowRendering.test.ts | 23 ++++++++ .../src/renderer/__tests__/diffStats.test.ts | 21 +++++++ .../components/chat/AgentActionRow.tsx | 27 ++++++--- .../renderer/lib/agent-actions/diffStats.ts | 11 ++-- .../src/__tests__/payloadSummary.test.ts | 42 ++++++++++++++ .../src/__tests__/toolUseDescriptor.test.ts | 58 +++++++++++++++++++ packages/maker-shared/src/payloadSummary.ts | 22 +++---- .../maker-shared/src/toolUseDescriptor.ts | 38 ++++++++++++ 8 files changed, 215 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts b/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts index b2e30cd740a..875d58312ff 100644 --- a/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts +++ b/apps/desktop/src/renderer/__tests__/agentActionRowRendering.test.ts @@ -302,6 +302,29 @@ describe('AgentActionRow — 行主文案', () => { expect(document.body.textContent).toContain('"newString":"new"'); }); + // pi 0.83.0 的 edit 同时接受 legacy 顶层单段(LegacyEditToolInput);只认 edits[] + // 会让这种事件退化成空 diff 与 +0 -0。 + it('pi edit:legacy 顶层 oldText/newText 也给出真实统计与非空 diff', () => { + render( + createElement(AgentActionRow, { + message: mkTool('t1', 'edit', { + path: '/repo/src/app.ts', + oldText: 'old A\nold B', + newText: 'new A', + }), + }), + ); + expect(screen.getByText('chat.agentActionRow.verb.edited')).toBeTruthy(); + expect(screen.getByText('app.ts')).toBeTruthy(); + expect(screen.getByText('+1')).toBeTruthy(); + expect(screen.getByText('-2')).toBeTruthy(); + fireEvent.click(screen.getByRole('button')); + expect(document.body.textContent).toContain('"oldString":"old A\\nold B"'); + expect(document.body.textContent).toContain('"newString":"new A"'); + // 空 diffs 数组会渲染成 "diffs":[] —— 明确断死它没退化。 + expect(document.body.textContent).not.toContain('"diffs":[]'); + }); + it('状态图标:running / done 经 aria-label 可达,缺省为 done', () => { const { rerender } = render( createElement(AgentActionRow, { diff --git a/apps/desktop/src/renderer/__tests__/diffStats.test.ts b/apps/desktop/src/renderer/__tests__/diffStats.test.ts index 26a6bed7f0e..5bfa4333c66 100644 --- a/apps/desktop/src/renderer/__tests__/diffStats.test.ts +++ b/apps/desktop/src/renderer/__tests__/diffStats.test.ts @@ -115,6 +115,27 @@ describe('statsForToolCall', () => { expect(stats).toEqual({ add: 3, del: 1 }); }); + it('pi edit: legacy top-level oldText/newText yields real counts, not +0 -0', () => { + expect(statsForToolCall('edit', { + path: '/foo', + oldText: 'old A\nold B', + newText: 'new A', + })).toEqual({ add: 1, del: 2 }); + }); + + it('pi edit: top-level pair is counted after edits[] when both are present', () => { + expect(statsForToolCall('edit', { + path: '/foo', + edits: [{ oldText: 'a', newText: 'b' }], // +1 -1 + oldText: 'c', + newText: 'd', // +1 -1 + })).toEqual({ add: 2, del: 2 }); + }); + + it('pi edit: no usable replacement returns +0 -0 (not null)', () => { + expect(statsForToolCall('edit', { path: '/foo' })).toEqual({ add: 0, del: 0 }); + }); + it('pi write: full content as +N -0', () => { expect(statsForToolCall('write', { path: '/foo', content: 'a\nb' })).toEqual({ add: 2, diff --git a/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx b/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx index 2cc89f8e3fa..7c6e18b3307 100644 --- a/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentActionRow.tsx @@ -45,6 +45,7 @@ import { useTranslation } from 'react-i18next'; import { describeToolUse, normalizeDisplayCommand, + piEditReplacements, type CommandIntent, type ToolUseDescriptor, } from '@cindy/maker-shared'; @@ -69,7 +70,16 @@ import { TextLightbox } from './TextLightbox'; import { ToolPayloadLightbox, type ToolPayloadMode } from './ToolPayloadLightbox'; import { useFileChipContextMenu } from './useFileChipContextMenu'; -// CC 大写 + pi 小写(pi 内置工具名全小写、文件字段为 path,见 toolUseDescriptor.ts)。 +/** + * 点击走「文件类」交互(diff / 文稿 / 图片 lightbox)的工具:CC 大写 + pi 小写 + * (pi 内置工具名全小写、文件字段为 path,见 toolUseDescriptor.ts)。 + * + * 注意这**不是**「所有 kind='file' 描述符」的集合:pi 的 `ls` 也被归一化成 + * kind='file'(读取语义)并渲染文件 chip,但**刻意不列入本集合** —— 它的目标是 + * 目录,开文稿/图片 lightbox 没有意义,因此点击仍走命令类的就地展开路径 + * (isInlineExpand)。新增工具时按「点击后该看到什么」判断是否入列,别按 + * 描述符 kind 判断。 + */ const FILE_PATH_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Read', 'edit', 'write', 'read']); /** @@ -558,21 +568,20 @@ function buildDiffPayload( ], }; } - // pi edit:edits[].oldText/newText,与 MultiEdit 同款多段 diff 呈现。 + // pi edit:声明 schema 的 edits[] 与 legacy 顶层 {oldText,newText} 两种形态, + // 由共享的 piEditReplacements 归一化(只认一种会让另一种退化成空 diff)。 if (toolName === 'edit') { - const edits = Array.isArray(inp.edits) ? inp.edits : []; return { kind: 'diff', files: [ { key: filePath, filePath, - diffs: edits.map((e, index) => { - const er = e as Record | null; - const o = er && typeof er.oldText === 'string' ? er.oldText : ''; - const n = er && typeof er.newText === 'string' ? er.newText : ''; - return { key: `edit:${index}`, oldString: String(o), newString: String(n) }; - }), + diffs: piEditReplacements(inp).map((edit, index) => ({ + key: `edit:${index}`, + oldString: edit.oldText, + newString: edit.newText, + })), }, ], }; diff --git a/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts b/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts index 04e5473608f..3e42bf59125 100644 --- a/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts +++ b/apps/desktop/src/renderer/lib/agent-actions/diffStats.ts @@ -14,6 +14,7 @@ */ import { diffLines } from 'diff'; +import { piEditReplacements } from '@cindy/maker-shared'; export interface DiffStat { add: number; @@ -77,16 +78,12 @@ export function statsForToolCall( return computeDiffStats('', c); } - // pi edit:edits[].oldText/newText,逐条求和(与 MultiEdit 同形态)。 + // pi edit:两种入参形态(edits[] + legacy 顶层单段)由共享归一化器抹平后逐段求和。 if (toolName === 'edit') { - const edits = Array.isArray(inp.edits) ? inp.edits : []; let add = 0; let del = 0; - for (const e of edits) { - const er = e as Record | null; - const o = er && typeof er.oldText === 'string' ? er.oldText : ''; - const n = er && typeof er.newText === 'string' ? er.newText : ''; - const s = computeDiffStats(o, n); + for (const edit of piEditReplacements(inp)) { + const s = computeDiffStats(edit.oldText, edit.newText); add += s.add; del += s.del; } diff --git a/packages/maker-shared/src/__tests__/payloadSummary.test.ts b/packages/maker-shared/src/__tests__/payloadSummary.test.ts index 692d6ec092f..ea44355f1fe 100644 --- a/packages/maker-shared/src/__tests__/payloadSummary.test.ts +++ b/packages/maker-shared/src/__tests__/payloadSummary.test.ts @@ -142,6 +142,48 @@ describe('payloadSummary', () => { expect(buildPayloadToolDiff('Edit', { old_string: 'a', new_string: 'b' })).toBeUndefined(); }); + it('builds pi edit diffs from both the declared edits[] and the legacy top-level shape', () => { + // 声明 schema 形态:{ path, edits: [{ oldText, newText }] }。 + expect(buildPayloadToolDiff('edit', { + path: '/repo/app.ts', + edits: [ + { oldText: 'a', newText: 'b' }, + { oldText: '', newText: 'c\nd' }, + ], + })).toEqual({ + deletions: 1, + filePath: '/repo/app.ts', + insertions: 3, + segments: [ + { key: 'edit:0', oldString: 'a', newString: 'b', label: 'Edit 1/2' }, + { key: 'edit:1', oldString: '', newString: 'c\nd', label: 'Edit 2/2' }, + ], + }); + + // legacy 顶层单段:{ path, oldText, newText } —— 必须产出真实 diff 而非空段。 + expect(buildPayloadToolDiff('edit', { + path: '/repo/app.ts', + oldText: 'old A\nold B', + newText: 'new A', + })).toEqual({ + deletions: 2, + filePath: '/repo/app.ts', + insertions: 1, + segments: [{ key: 'edit:0', oldString: 'old A\nold B', newString: 'new A' }], + }); + + // pi write 用 path + content。 + expect(buildPayloadToolDiff('write', { + path: '/repo/new.ts', + content: 'line A\nline B', + })).toEqual({ + deletions: 0, + filePath: '/repo/new.ts', + insertions: 2, + segments: [{ key: 'write:0', oldString: '', newString: 'line A\nline B' }], + }); + }); + it('summarizes payload chrome data without UI dependencies', () => { expect(summarizeMessagePayload(buildDiffPayload(diff))).toEqual({ kind: 'diff', diff --git a/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts b/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts index 9943a08c7c6..91846eb1ae1 100644 --- a/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts +++ b/packages/maker-shared/src/__tests__/toolUseDescriptor.test.ts @@ -3,6 +3,7 @@ import { describeToolUse, humanizeToolToken, parseToolName, + piEditReplacements, truncateToolText, } from '../toolUseDescriptor'; @@ -227,6 +228,15 @@ describe('describeToolUse — pi builtin tools (lowercase, path field)', () => { expect(describeToolUse('ls', {})).toEqual({ kind: 'generic', toolName: 'ls' }); }); + it('maps pi read/edit/write/ls to file actions regardless of edit input shape', () => { + // legacy 顶层形态也必须仍然是 file/edit 描述符(路径来自 path)。 + expect(describeToolUse('edit', { + path: '/repo/a.ts', + oldText: 'x', + newText: 'y', + })).toMatchObject({ kind: 'file', action: 'edit', fileName: 'a.ts' }); + }); + it('maps pi grep to grep-mode and pi find (glob pattern) to glob-mode search', () => { expect(describeToolUse('grep', { pattern: 'TODO', path: 'src/', glob: '*.ts' })).toEqual({ kind: 'search', @@ -441,3 +451,51 @@ describe('helpers', () => { expect(truncateToolText('a'.repeat(12), 10)).toBe(`${'a'.repeat(7)}...`); }); }); + +describe('piEditReplacements', () => { + it('reads the declared edits[] shape in order', () => { + expect(piEditReplacements({ + path: '/repo/a.ts', + edits: [{ oldText: 'a', newText: 'b' }, { oldText: 'c', newText: 'd' }], + })).toEqual([ + { oldText: 'a', newText: 'b' }, + { oldText: 'c', newText: 'd' }, + ]); + }); + + it('reads the legacy top-level { oldText, newText } single replacement', () => { + expect(piEditReplacements({ path: '/repo/a.ts', oldText: 'x', newText: 'y' })).toEqual([ + { oldText: 'x', newText: 'y' }, + ]); + }); + + it('appends the top-level pair after edits[], mirroring pi normalizeEditInput', () => { + expect(piEditReplacements({ + path: '/repo/a.ts', + edits: [{ oldText: 'a', newText: 'b' }], + oldText: 'x', + newText: 'y', + })).toEqual([ + { oldText: 'a', newText: 'b' }, + { oldText: 'x', newText: 'y' }, + ]); + }); + + it('keeps pure insert/delete segments and fills the missing side with an empty string', () => { + expect(piEditReplacements({ edits: [{ newText: 'added' }, { oldText: 'removed' }] })).toEqual([ + { oldText: '', newText: 'added' }, + { oldText: 'removed', newText: '' }, + ]); + }); + + it('ignores unusable input instead of throwing', () => { + expect(piEditReplacements(null)).toEqual([]); + expect(piEditReplacements('oops')).toEqual([]); + expect(piEditReplacements({ path: '/repo/a.ts' })).toEqual([]); + expect(piEditReplacements({ edits: 'nope' })).toEqual([]); + expect(piEditReplacements({ edits: [null, 42, {}] })).toEqual([]); + // 顶层只给一半不成段(pi 自己也要求两侧都是字符串才归一化)。 + expect(piEditReplacements({ oldText: 'x' })).toEqual([]); + expect(piEditReplacements({ newText: 'y' })).toEqual([]); + }); +}); diff --git a/packages/maker-shared/src/payloadSummary.ts b/packages/maker-shared/src/payloadSummary.ts index 99a00e08858..fd7fab0bb5d 100644 --- a/packages/maker-shared/src/payloadSummary.ts +++ b/packages/maker-shared/src/payloadSummary.ts @@ -1,3 +1,5 @@ +import { piEditReplacements } from './toolUseDescriptor.js'; + export type PayloadKind = 'text' | 'diff' | 'media' | 'mermaid' | 'file'; export interface PayloadToolDiffLike { @@ -252,18 +254,16 @@ export function buildPayloadToolDiff(toolName: string, input: unknown): PayloadT const newString = typeof inp.content === 'string' ? inp.content : ''; return createPayloadToolDiff(filePath, [{ key: 'write:0', oldString: '', newString }]); } - // pi edit:edits[].oldText/newText(与 MultiEdit 同款多段 diff)。 + // pi edit:两种入参形态由 piEditReplacements 统一归一化(edits[] + legacy 顶层单段)。 if (toolName === 'edit') { - const edits = Array.isArray(inp.edits) ? inp.edits : []; - return createPayloadToolDiff(filePath, edits.map((edit, index) => { - const record = readPayloadRecord(edit); - return { - key: `edit:${index}`, - oldString: typeof record?.oldText === 'string' ? record.oldText : '', - newString: typeof record?.newText === 'string' ? record.newText : '', - label: `Edit ${index + 1}/${edits.length}`, - }; - })); + const replacements = piEditReplacements(inp); + return createPayloadToolDiff(filePath, replacements.map((edit, index) => ({ + key: `edit:${index}`, + oldString: edit.oldText, + newString: edit.newText, + // 单段时不标 1/1 —— 顶层 legacy 形态就是单段,标号只是噪音。 + ...(replacements.length > 1 ? { label: `Edit ${index + 1}/${replacements.length}` } : {}), + }))); } if (toolName === 'MultiEdit') { const edits = Array.isArray(inp.edits) ? inp.edits : []; diff --git a/packages/maker-shared/src/toolUseDescriptor.ts b/packages/maker-shared/src/toolUseDescriptor.ts index bb2cc335e94..9c7af4c167f 100644 --- a/packages/maker-shared/src/toolUseDescriptor.ts +++ b/packages/maker-shared/src/toolUseDescriptor.ts @@ -164,6 +164,44 @@ export type ToolUseDescriptor = } | { kind: 'generic'; toolName: string; detail?: string }; +/** pi `edit` 的一段定向替换。 */ +export interface PiEditReplacement { + oldText: string; + newText: string; +} + +/** + * 归一化 pi `edit` 工具的替换段。 + * + * pi v0.83.0 的 `edit` 有**两种**入参形态,展示层必须都认(`edit.ts` 的 + * `editSchema` 与 `LegacyEditToolInput` / `normalizeEditInput`): + * - 声明 schema(模型被要求产出的形态):`{ path, edits: [{ oldText, newText }] }`; + * - legacy 顶层单段:`{ path, oldText, newText }` —— pi 自己仍接受并归一化。 + * + * 顺序与 pi 的 `normalizeEditInput` 对齐:先取 `edits[]`,再把顶层 + * `oldText`/`newText`(两者都是字符串才认)作为**最后一段**追加。只认其中之一 + * 会让另一种形态退化成空 diff 与 `+0 -0`。 + */ +export function piEditReplacements(input: unknown): PiEditReplacement[] { + const inp = readRecord(input); + if (!inp) return []; + const out: PiEditReplacement[] = []; + if (Array.isArray(inp.edits)) { + for (const raw of inp.edits) { + const rec = readRecord(raw); + // 单段内只要有一侧是字符串就成段(另一侧按空串),纯增/纯删才不会被丢掉。 + const oldText = typeof rec?.oldText === 'string' ? rec.oldText : undefined; + const newText = typeof rec?.newText === 'string' ? rec.newText : undefined; + if (oldText === undefined && newText === undefined) continue; + out.push({ oldText: oldText ?? '', newText: newText ?? '' }); + } + } + if (typeof inp.oldText === 'string' && typeof inp.newText === 'string') { + out.push({ oldText: inp.oldText, newText: inp.newText }); + } + return out; +} + /** 下划线(含双下划线)转空格并收敛连续空白,得到可读的 token。 */ export function humanizeToolToken(token: string): string { return token.replace(/_+/g, ' ').replace(/\s+/g, ' ').trim();