diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts index 388c85bc13c..c41f3d219f4 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts @@ -231,6 +231,11 @@ describe('ToolCallEmitter', () => { _meta: { toolName: 'edit_file', provenance: 'builtin' }, }), ); + expect(sendUpdateSpy.mock.calls[0][0].rawOutput).toEqual({ + fileName: '/test/file.ts', + originalContent: 'old content', + newContent: 'new content', + }); }); it('should not replay truncated session previews as full diffs', async () => { @@ -266,6 +271,7 @@ describe('ToolCallEmitter', () => { _meta: { toolName: 'edit_file', provenance: 'builtin' }, }), ); + expect(sendUpdateSpy.mock.calls[0][0].rawOutput).toBeUndefined(); }); it('should transform message parts to content', async () => { diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index 4cfbfb5ee01..1eac67fa84b 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -171,7 +171,10 @@ export class ToolCallEmitter extends BaseEmitter { }; // Add rawOutput from resultDisplay - if (params.resultDisplay !== undefined) { + if ( + params.resultDisplay !== undefined && + !this.isTruncatedSessionDiffDisplay(params.resultDisplay) + ) { (update as Record)['rawOutput'] = params.resultDisplay; } @@ -353,7 +356,7 @@ export class ToolCallEmitter extends BaseEmitter { // Check if this is a diff display (edit tool result) if ('fileName' in obj && 'newContent' in obj) { - if (obj['truncatedForSession'] === true) { + if (this.isTruncatedSessionDiffDisplay(resultDisplay)) { return { type: 'content', content: { @@ -374,6 +377,17 @@ export class ToolCallEmitter extends BaseEmitter { return null; } + private isTruncatedSessionDiffDisplay(resultDisplay: unknown): boolean { + if (!resultDisplay || typeof resultDisplay !== 'object') return false; + + const obj = resultDisplay as Record; + return ( + obj['truncatedForSession'] === true && + 'fileName' in obj && + 'newContent' in obj + ); + } + /** * Transforms Part[] to ToolCallContent[]. * Extracts text from functionResponse parts and text parts. diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index 69b616b229b..f062fa86dce 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -2328,6 +2328,12 @@ describe('transcriptBlocksToDaemonMessages', () => { const tool = messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; expect(tool?.rawOutput).toBeUndefined(); + expect(tool?.content).toEqual([ + { + type: 'content', + content: { type: 'text', text: 'rendered elsewhere' }, + }, + ]); }); it('mergeToolCall updates fields from completion block', () => { @@ -2582,7 +2588,7 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(execTool?.subContent).toBe('Running...'); }); - it('does not pass content, locations, or preview to DaemonMessageToolCall', () => { + it('passes content but not locations or preview to DaemonMessageToolCall', () => { const messages = transcriptBlocksToDaemonMessages([ toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Edit', @@ -2603,7 +2609,14 @@ describe('transcriptBlocksToDaemonMessages', () => { messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; expect(tool).toBeDefined(); expect(tool?.callId).toBe('tc1'); - expect('content' in tool!).toBe(false); + expect(tool?.content).toEqual([ + { + type: 'diff', + path: '/path/file.ts', + oldText: 'old', + newText: 'new', + }, + ]); expect('locations' in tool!).toBe(false); expect('preview' in tool!).toBe(false); }); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 5df8795355b..70d1e29b53b 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -15,6 +15,7 @@ import type { import type { DaemonMessage, DaemonMessageToolCall, + DaemonMessageToolCallContent, DaemonMessageToolCallStatus, DaemonMessageToolKind, DaemonMessageTodoItem, @@ -645,6 +646,7 @@ function daemonToolBlockToToolCall( ): DaemonMessageToolCall { const rawOutput = getToolRawOutput(block); const isBackgroundAgent = isBackgroundAgentBlock(block, rawOutput); + const content = normalizeToolContent(block.content); const statusMap: Record = { running: 'in_progress', pending: 'pending', @@ -676,6 +678,7 @@ function daemonToolBlockToToolCall( parentToolCallId: block.parentToolCallId, startTime: block.createdAt, endTime: isComplete && !isBackgroundAgent ? block.updatedAt : undefined, + ...(content ? { content } : {}), }; } @@ -813,6 +816,59 @@ function getToolRawOutput(block: DaemonToolTranscriptBlock): unknown { }; } +function normalizeToolContent( + value: unknown, +): DaemonMessageToolCallContent[] | undefined { + if (!Array.isArray(value)) return undefined; + + const content = value.flatMap((entry): DaemonMessageToolCallContent[] => { + const item = getRecord(entry); + if (!item) return []; + + const type = item['type']; + if (type === 'content') { + const body = getRecord(item['content']); + if (!body || typeof body['type'] !== 'string') return []; + return [ + { + type: 'content', + content: { ...body, type: body['type'] }, + }, + ]; + } + + if (type === 'diff') { + const newText = item['newText']; + if (typeof newText !== 'string') return []; + + const path = item['path']; + const oldText = item['oldText']; + return [ + { + type: 'diff', + ...(typeof path === 'string' ? { path } : {}), + ...(typeof oldText === 'string' ? { oldText } : {}), + newText, + }, + ]; + } + + if (type === 'terminal') { + const terminalId = item['terminalId']; + return [ + { + type: 'terminal', + ...(typeof terminalId === 'string' ? { terminalId } : {}), + }, + ]; + } + + return []; + }); + + return content.length > 0 ? content : undefined; +} + function isAskUserQuestionBlock(block: DaemonToolTranscriptBlock): boolean { if (!block.toolName) return false; const normalized = block.toolName.toLowerCase(); diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 7bd0c1214d3..cafd4606fdd 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -39,6 +39,15 @@ function makeShellTool( }; } +function makeEditTool(overrides: Partial): ACPToolCall { + return { + callId: 'call-edit-1', + toolName: 'edit', + status: 'completed', + ...overrides, + }; +} + function renderTool(tool: ACPToolCall): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); @@ -104,6 +113,14 @@ function click(el: Element): void { }); } +function expandTool(container: HTMLElement): void { + const chevron = [...container.querySelectorAll('span')].find( + (s) => s.textContent === '▸', + ); + expect(chevron).toBeTruthy(); + click(chevron!.parentElement!); +} + describe('shell tool output expand toggle', () => { it('shows short output in full without an expand button', () => { const container = renderShellTool('one\ntwo\nthree'); @@ -347,3 +364,104 @@ describe('auto-collapse on finish', () => { expect(container.textContent).toContain('▾'); }); }); + +describe('edit raw diff rendering', () => { + it('ignores truncated session rawOutput diffs', () => { + const fullDiff = '--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new'; + + const normal = renderTool( + makeEditTool({ + rawOutput: { + fileDiff: fullDiff, + }, + }), + ); + expandTool(normal); + expect(normal.textContent).toContain('old'); + expect(normal.textContent).toContain('new'); + + const truncated = renderTool( + makeEditTool({ + rawOutput: { + fileName: '/test/file.ts', + newContent: 'preview only', + fileDiff: fullDiff, + truncatedForSession: true, + }, + }), + ); + expect(truncated.textContent).not.toContain('old'); + expect(truncated.textContent).not.toContain('new'); + }); + + it('shows preview and suppresses truncated rawOutput diffs', () => { + const fullDiff = '--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new'; + const preview = + 'Full diff omitted from saved session history for /test/file.ts.'; + const container = renderTool( + makeEditTool({ + content: [ + { + type: 'content', + content: { type: 'text', text: preview }, + }, + ], + rawOutput: { + fileName: '/test/file.ts', + newContent: 'preview only', + fileDiff: fullDiff, + truncatedForSession: true, + }, + }), + ); + + expandTool(container); + expect(container.textContent).toContain(preview); + expect(container.textContent).not.toContain('old'); + expect(container.textContent).not.toContain('new'); + }); + + it('renders truncated session preview text when no diff is available', () => { + const preview = + 'Full diff omitted from saved session history for /test/file.ts.'; + const container = renderTool( + makeEditTool({ + content: [ + { + type: 'content', + content: { type: 'text', text: preview }, + }, + ], + }), + ); + + expandTool(container); + expect(container.textContent).toContain(preview); + }); + + it('expands write preview text when no diff is available', () => { + const preview = + 'Full diff omitted from saved session history for /test/file.ts.'; + const container = renderTool( + makeEditTool({ + toolName: 'write', + content: [ + { + type: 'content', + content: { type: 'text', text: preview }, + }, + ], + }), + ); + + expect(container.querySelector('pre')).toBeNull(); + + const writeLabel = Array.from(container.querySelectorAll('span')).find( + (el) => el.textContent === 'WriteFile', + ); + expect(writeLabel).toBeDefined(); + click(writeLabel!); + + expect(container.querySelector('pre')?.textContent).toBe(preview); + }); +}); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 2b00a16367e..cbea5873ba2 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -64,7 +64,7 @@ function hasExpandableContent(tool: ACPToolCall): boolean { if (isAskUserQuestionToolName(tool.toolName)) return !!extractText(tool); // write_file shows content from args even before completion if (name === 'write_file' || name === 'writefile') { - return !!getWriteContent(tool); + return !!getWriteContent(tool) || hasEditContent(tool); } if (tool.status !== 'completed' && tool.status !== 'failed') return false; if (isShellToolName(name)) { @@ -72,7 +72,7 @@ function hasExpandableContent(tool: ACPToolCall): boolean { return !!text && text.trim().length > 0 && text.split('\n').length > 1; } if (name === 'edit' || name === 'write' || name === 'editfile') { - return hasDiffContent(tool); + return hasEditContent(tool); } if (name === 'read' || name === 'read_file' || name === 'readfile') { const text = extractText(tool); @@ -104,11 +104,11 @@ function hasDetailView(tool: ACPToolCall): boolean { function hasDiffContent(tool: ACPToolCall): boolean { if (tool.content?.some((b) => b.type === 'diff')) return true; - if (tool.rawOutput && typeof tool.rawOutput === 'object') { - const raw = tool.rawOutput as Record; - if (typeof raw.fileDiff === 'string' && raw.fileDiff) return true; - } - return false; + return !!getRawFileDiff(tool); +} + +function hasEditContent(tool: ACPToolCall): boolean { + return hasDiffContent(tool) || !!extractText(tool); } function extractDiff(tool: ACPToolCall): string { @@ -118,13 +118,24 @@ function extractDiff(tool: ACPToolCall): string { return buildUnifiedDiff(diffBlock.oldText || '', diffBlock.newText || ''); } } + return getRawFileDiff(tool); +} + +function getRawFileDiff(tool: ACPToolCall): string { if (tool.rawOutput && typeof tool.rawOutput === 'object') { const raw = tool.rawOutput as Record; + if (isTruncatedSessionDiff(raw)) return ''; if (typeof raw.fileDiff === 'string') return raw.fileDiff; } return ''; } +function isTruncatedSessionDiff(raw: Record): boolean { + return ( + raw.truncatedForSession === true && 'fileName' in raw && 'newContent' in raw + ); +} + const MAX_DIFF_PRODUCT = 250_000; function buildUnifiedDiff(oldText: string, newText: string): string { @@ -294,12 +305,17 @@ function ExpandedReadContent({ tool }: { tool: ACPToolCall }) { ); } -function ExpandedEditDiff({ tool }: { tool: ACPToolCall }) { +function ExpandedEditContent({ tool }: { tool: ACPToolCall }) { const diff = useMemo(() => extractDiff(tool), [tool]); - if (!diff) return null; + const text = useMemo(() => extractText(tool) || '', [tool]); + if (!diff && !text) return null; return (
- + {diff ? ( + + ) : ( +
{text}
+ )}
); } @@ -845,10 +861,10 @@ export const ToolLine = memo(function ToolLine({ )} {(name === 'write_file' || name === 'writefile') && ( - + )} {(name === 'edit' || name === 'write' || name === 'editfile') && ( - + )} {(name === 'read' || name === 'read_file' || name === 'readfile') && (