From 9315d93b92e870b4afecd96fb5c89b994fe56a58 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 6 May 2026 16:29:24 +0800 Subject: [PATCH 1/2] fix(core): shrink file diff session records Trim oversized file edit result displays before writing them to session JSONL while preserving live tool results and diff stats. Also make resume, ACP replay, and export paths treat saved previews as incomplete so they do not reconstruct fake full diffs. Co-authored-by: Qwen-Coder --- .../session/emitters/ToolCallEmitter.test.ts | 35 ++++ .../session/emitters/ToolCallEmitter.ts | 29 +++ .../components/messages/ToolMessage.test.tsx | 21 +++ .../ui/components/messages/ToolMessage.tsx | 50 ++++-- .../cli/src/ui/utils/export/collect.test.ts | 89 ++++++++++ packages/cli/src/ui/utils/export/collect.ts | 3 +- .../cli/src/ui/utils/export/normalize.test.ts | 72 ++++++++ packages/cli/src/ui/utils/export/normalize.ts | 31 ++++ .../src/services/chatRecordingService.test.ts | 165 ++++++++++++++++++ .../core/src/services/chatRecordingService.ts | 146 +++++++++++++++- packages/core/src/tools/tools.ts | 7 + 11 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/ui/utils/export/collect.test.ts create mode 100644 packages/cli/src/ui/utils/export/normalize.test.ts 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 6b0e6449e11..6acc3022213 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts @@ -233,6 +233,41 @@ describe('ToolCallEmitter', () => { ); }); + it('should not replay truncated session previews as full diffs', async () => { + await emitter.emitResult({ + toolName: 'edit_file', + callId: 'call-edit', + success: true, + message: [], + resultDisplay: { + fileName: '/test/file.ts', + originalContent: 'old preview', + newContent: 'new preview', + truncatedForSession: true, + fileDiffLength: 200000, + fileDiffTruncated: true, + }, + }); + + expect(sendUpdateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-edit', + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'Full diff omitted from saved session history for /test/file.ts. Original fileDiff length: 200000 chars.', + }, + }, + ], + _meta: { toolName: 'edit_file' }, + }), + ); + }); + it('should transform message parts to content', async () => { await emitter.emitResult({ toolName: 'test_tool', diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index a84e4f4e7da..106cf49b516 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -21,6 +21,25 @@ import type { import type { Part } from '@google/genai'; import { ToolNames, Kind } from '@qwen-code/qwen-code-core'; +function buildTruncatedDiffReplayText( + display: Record, +): string { + const fileName = + typeof display['fileName'] === 'string' + ? display['fileName'] + : 'the edited file'; + const fileDiffLength = + typeof display['fileDiffLength'] === 'number' + ? ` Original fileDiff length: ${display['fileDiffLength']} chars.` + : ''; + + if (display['fileDiffTruncated'] === true) { + return `Full diff omitted from saved session history for ${fileName}.${fileDiffLength}`; + } + + return `Saved session preview only for ${fileName}; full original and new file contents are unavailable.`; +} + /** * Unified tool call event emitter. * @@ -273,6 +292,16 @@ 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) { + return { + type: 'content', + content: { + type: 'text', + text: buildTruncatedDiffReplayText(obj), + }, + }; + } + return { type: 'diff', path: obj['fileName'] as string, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 568c9c12c13..274b391487b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -274,6 +274,27 @@ describe('', () => { expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/); }); + it('renders a saved-session preview notice for truncated diff results', () => { + const diffResult = { + fileDiff: '--- file.txt\n+++ file.txt\n@@ -1 +1 @@\n-omitted\n+preview', + fileName: 'file.txt', + originalContent: 'old preview', + newContent: 'new preview', + truncatedForSession: true, + fileDiffLength: 123456, + fileDiffTruncated: true, + }; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + expect(lastFrame()).toContain( + 'Saved session preview only; full diff omitted from JSONL (123456 chars).', + ); + expect(lastFrame()).toContain('MockDiff:--- file.txt'); + }); + it('renders emphasis correctly', () => { const { lastFrame: highEmphasisFrame } = renderWithContext( , diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index ebdfed81211..314ded5acdb 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -22,6 +22,7 @@ import type { AnsiOutputDisplay, Config, McpToolProgressData, + FileDiff, } from '@qwen-code/qwen-code-core'; import { AgentExecutionDisplay } from '../subagents/index.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; @@ -49,6 +50,14 @@ const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5; // outputs that will get truncated further MaxSizedBox anyway. const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000; export type TextEmphasis = 'high' | 'medium' | 'low'; +type DiffResultDisplay = Pick< + FileDiff, + | 'fileDiff' + | 'fileName' + | 'truncatedForSession' + | 'fileDiffLength' + | 'fileDiffTruncated' +>; function sliceTextForMaxHeight( text: string, @@ -175,7 +184,7 @@ const useResultDisplayRenderer = ( ) { return { type: 'diff', - data: resultDisplay as { fileDiff: string; fileName: string }, + data: resultDisplay as DiffResultDisplay, }; } @@ -378,19 +387,38 @@ const StringResultRenderer: React.FC<{ * Component to render diff results */ const DiffResultRenderer: React.FC<{ - data: { fileDiff: string; fileName: string }; + data: DiffResultDisplay; availableHeight?: number; childWidth: number; settings?: LoadedSettings; -}> = ({ data, availableHeight, childWidth, settings }) => ( - -); +}> = ({ data, availableHeight, childWidth, settings }) => { + const diffHeight = + data.truncatedForSession && availableHeight !== undefined + ? Math.max(1, availableHeight - 1) + : availableHeight; + + return ( + + {data.truncatedForSession && ( + + {data.fileDiffTruncated + ? 'Saved session preview only; full diff omitted from JSONL' + : 'Saved session preview only; full file contents truncated in JSONL'} + {data.fileDiffTruncated && typeof data.fileDiffLength === 'number' + ? ` (${data.fileDiffLength} chars).` + : '.'} + + )} + + + ); +}; export interface ToolMessageProps extends IndividualToolCallDisplay { availableTerminalHeight?: number; diff --git a/packages/cli/src/ui/utils/export/collect.test.ts b/packages/cli/src/ui/utils/export/collect.test.ts new file mode 100644 index 00000000000..0789bc11f56 --- /dev/null +++ b/packages/cli/src/ui/utils/export/collect.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { ChatRecord, Config } from '@qwen-code/qwen-code-core'; +import { collectSessionData } from './collect.js'; + +describe('collectSessionData', () => { + const config = { + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn().mockReturnValue(null), + }), + } as unknown as Config; + + it('skips line-count fallback for truncated saved-session previews', async () => { + const records: ChatRecord[] = [ + { + uuid: 'assistant-1', + parentUuid: null, + sessionId: 'session-1', + timestamp: '2025-01-01T00:00:00.000Z', + type: 'assistant', + cwd: '', + version: '1.0.0', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'edit_file', + args: { file_path: '/test/file.ts' }, + }, + }, + ], + }, + }, + { + uuid: 'tool-1', + parentUuid: 'assistant-1', + sessionId: 'session-1', + timestamp: '2025-01-01T00:00:01.000Z', + type: 'tool_result', + cwd: '', + version: '1.0.0', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'edit_file', + response: { output: 'ok' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'call-1', + resultDisplay: { + fileName: 'file.ts', + fileDiff: + '--- file.ts\n+++ file.ts\n@@ -1,2 +1,2 @@\n-old\n-preview\n+new\n+preview', + originalContent: 'old\npreview', + newContent: 'new\npreview', + truncatedForSession: true, + }, + }, + }, + ]; + + const data = await collectSessionData( + { + sessionId: 'session-1', + startTime: '2025-01-01T00:00:00.000Z', + messages: records, + }, + config, + ); + + expect(data.metadata?.filesWritten).toBe(1); + expect(data.metadata?.uniqueFiles).toEqual(['/test/file.ts']); + expect(data.metadata?.linesAdded).toBe(0); + expect(data.metadata?.linesRemoved).toBe(0); + }); +}); diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index d929e0b41e9..04c374031c3 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -171,6 +171,7 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { originalContent?: string | null; newContent?: string; diffStat?: { model_added_lines?: number; model_removed_lines?: number }; + truncatedForSession?: boolean; }; // Determine operation type based on content fields @@ -197,7 +198,7 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { // Use diffStat if available for accurate counts stats.linesAdded += display.diffStat.model_added_lines ?? 0; stats.linesRemoved += display.diffStat.model_removed_lines ?? 0; - } else { + } else if (!display.truncatedForSession) { // Fallback: count lines in content const oldText = String(display.originalContent ?? ''); const newText = String(display.newContent ?? ''); diff --git a/packages/cli/src/ui/utils/export/normalize.test.ts b/packages/cli/src/ui/utils/export/normalize.test.ts new file mode 100644 index 00000000000..ba2c362d08e --- /dev/null +++ b/packages/cli/src/ui/utils/export/normalize.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { ChatRecord, Config } from '@qwen-code/qwen-code-core'; +import { normalizeSessionData } from './normalize.js'; + +describe('normalizeSessionData', () => { + const config = { + getToolRegistry: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + it('does not export truncated saved-session previews as full diffs', () => { + const record: ChatRecord = { + uuid: 'tool-1', + parentUuid: null, + sessionId: 'session-1', + timestamp: '2025-01-01T00:00:00.000Z', + type: 'tool_result', + cwd: '', + version: '1.0.0', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'edit_file', + response: { output: 'ok' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'call-1', + resultDisplay: { + fileName: '/test/file.ts', + fileDiff: + '--- /test/file.ts\n+++ /test/file.ts\n@@ -1 +1 @@\n-omitted\n+preview', + originalContent: 'old preview', + newContent: 'new preview', + truncatedForSession: true, + fileDiffLength: 200000, + fileDiffTruncated: true, + }, + }, + }; + + const normalized = normalizeSessionData( + { + sessionId: 'session-1', + startTime: '2025-01-01T00:00:00.000Z', + messages: [], + }, + [record], + config, + ); + + expect(normalized.messages[0].toolCall?.content).toEqual([ + { + type: 'content', + content: { + type: 'text', + text: 'Full diff omitted from saved session history for /test/file.ts. Original fileDiff length: 200000 chars.', + }, + }, + ]); + }); +}); diff --git a/packages/cli/src/ui/utils/export/normalize.ts b/packages/cli/src/ui/utils/export/normalize.ts index 99ab62c329d..ac32acd717d 100644 --- a/packages/cli/src/ui/utils/export/normalize.ts +++ b/packages/cli/src/ui/utils/export/normalize.ts @@ -9,6 +9,25 @@ import { ToolNames } from '@qwen-code/qwen-code-core'; import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core'; import type { ExportMessage, ExportSessionData } from './types.js'; +function buildTruncatedDiffExportText( + display: Record, +): string { + const fileName = + typeof display['fileName'] === 'string' + ? display['fileName'] + : 'the edited file'; + const fileDiffLength = + typeof display['fileDiffLength'] === 'number' + ? ` Original fileDiff length: ${display['fileDiffLength']} chars.` + : ''; + + if (display['fileDiffTruncated'] === true) { + return `Full diff omitted from saved session history for ${fileName}.${fileDiffLength}`; + } + + return `Saved session preview only for ${fileName}; full original and new file contents are unavailable.`; +} + /** * Normalizes export session data by merging tool call information from tool_result records. * This ensures the SSOT contains complete tool call metadata. @@ -283,6 +302,18 @@ function extractDiffContent( const display = resultDisplay as Record; if ('fileName' in display && 'newContent' in display) { + if (display['truncatedForSession'] === true) { + return [ + { + type: 'content', + content: { + type: 'text', + text: buildTruncatedDiffExportText(display), + }, + }, + ]; + } + return [ { type: 'diff', diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 15173c32bc9..2bbc6143e23 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -17,6 +17,7 @@ import { } from './chatRecordingService.js'; import * as jsonl from '../utils/jsonl-utils.js'; import type { Part } from '@google/genai'; +import type { FileDiff } from '../tools/tools.js'; vi.mock('node:path'); vi.mock('node:child_process'); @@ -290,6 +291,170 @@ describe('ChatRecordingService', () => { expect(record.toolCallResult?.callId).toBe('call-1'); }); + it('should keep small file diff resultDisplay unchanged', async () => { + const toolResultParts: Part[] = [ + { + functionResponse: { + id: 'call-1', + name: 'edit', + response: { output: 'ok' }, + }, + }, + ]; + const resultDisplay: FileDiff = { + fileName: 'file.txt', + fileDiff: '--- file.txt\n+++ file.txt\n@@ -1 +1 @@\n-old\n+new', + originalContent: 'old', + newContent: 'new', + diffStat: { + model_added_lines: 1, + model_removed_lines: 1, + model_added_chars: 3, + model_removed_chars: 3, + user_added_lines: 0, + user_removed_lines: 0, + user_added_chars: 0, + user_removed_chars: 0, + }, + }; + const metadata = { + callId: 'call-1', + status: 'success' as const, + responseParts: toolResultParts, + resultDisplay, + error: undefined, + errorType: undefined, + }; + + chatRecordingService.recordToolResult(toolResultParts, metadata); + await chatRecordingService.flush(); + + const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; + + expect(record.toolCallResult?.resultDisplay).toBe(resultDisplay); + expect( + (record.toolCallResult?.resultDisplay as FileDiff).truncatedForSession, + ).toBeUndefined(); + }); + + it('should shrink large file diff resultDisplay without mutating input', async () => { + const toolResultParts: Part[] = [ + { + functionResponse: { + id: 'call-1', + name: 'write_file', + response: { output: 'ok' }, + }, + }, + ]; + const largeDiff = 'd'.repeat(70_000); + const largeOriginal = 'a'.repeat(20_000); + const largeNew = 'b'.repeat(20_000); + const resultDisplay: FileDiff = { + fileName: 'large.txt', + fileDiff: largeDiff, + originalContent: largeOriginal, + newContent: largeNew, + diffStat: { + model_added_lines: 1, + model_removed_lines: 1, + model_added_chars: largeNew.length, + model_removed_chars: largeOriginal.length, + user_added_lines: 0, + user_removed_lines: 0, + user_added_chars: 0, + user_removed_chars: 0, + }, + }; + const metadata = { + callId: 'call-1', + status: 'success' as const, + responseParts: toolResultParts, + resultDisplay, + error: undefined, + errorType: undefined, + }; + + chatRecordingService.recordToolResult(toolResultParts, metadata); + await chatRecordingService.flush(); + + const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; + const savedDisplay = record.toolCallResult?.resultDisplay as FileDiff; + + expect(savedDisplay).not.toBe(resultDisplay); + expect(savedDisplay.truncatedForSession).toBe(true); + expect(savedDisplay.fileDiffLength).toBe(largeDiff.length); + expect(savedDisplay.originalContentLength).toBe(largeOriginal.length); + expect(savedDisplay.newContentLength).toBe(largeNew.length); + expect(savedDisplay.fileDiffTruncated).toBe(true); + expect(savedDisplay.originalContentTruncated).toBe(true); + expect(savedDisplay.newContentTruncated).toBe(true); + expect(savedDisplay.fileDiff).toContain( + 'Full diff omitted from saved session history', + ); + expect(savedDisplay.fileDiff).not.toBe(largeDiff); + expect(savedDisplay.originalContent?.length).toBeLessThanOrEqual(16_000); + expect(savedDisplay.originalContent).toContain( + 'truncated for saved session preview', + ); + expect(savedDisplay.newContent.length).toBeLessThanOrEqual(16_000); + expect(savedDisplay.newContent).toContain( + 'truncated for saved session preview', + ); + expect(savedDisplay.diffStat).toEqual(resultDisplay.diffStat); + + expect(resultDisplay.fileDiff).toBe(largeDiff); + expect(resultDisplay.originalContent).toBe(largeOriginal); + expect(resultDisplay.newContent).toBe(largeNew); + expect(resultDisplay.truncatedForSession).toBeUndefined(); + }); + + it('should continue stripping nested tool calls from task execution results', async () => { + const toolResultParts: Part[] = [ + { + functionResponse: { + id: 'call-1', + name: 'task', + response: { output: 'ok' }, + }, + }, + ]; + const metadata = { + callId: 'call-1', + status: 'success' as const, + responseParts: toolResultParts, + resultDisplay: { + type: 'task_execution' as const, + subagentName: 'Task', + taskDescription: 'Run task', + taskPrompt: 'Run task', + status: 'completed' as const, + result: 'done', + toolCalls: [ + { + callId: 'nested-call', + name: 'read_file', + status: 'success' as const, + args: {}, + result: 'nested result', + }, + ], + }, + error: undefined, + errorType: undefined, + }; + + chatRecordingService.recordToolResult(toolResultParts, metadata); + await chatRecordingService.flush(); + + const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; + + expect(record.toolCallResult?.resultDisplay).toMatchObject({ + type: 'task_execution', + toolCalls: [], + }); + }); + it('should chain tool result correctly with parentUuid', async () => { chatRecordingService.recordUserMessage([{ text: 'Hello' }]); chatRecordingService.recordAssistantTurn({ diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 35be0ed5f00..2404983728f 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -25,7 +25,7 @@ import type { ToolCallResponseInfo, } from '../core/turn.js'; import type { Status } from '../core/coreToolScheduler.js'; -import type { AgentResultDisplay } from '../tools/tools.js'; +import type { AgentResultDisplay, FileDiff } from '../tools/tools.js'; import type { UiEvent } from '../telemetry/uiTelemetry.js'; const debugLogger = createDebugLogger('CHAT_RECORDING'); @@ -36,6 +36,132 @@ const debugLogger = createDebugLogger('CHAT_RECORDING'); * retrying across turns. */ const AUTO_TITLE_ATTEMPT_CAP = 3; +const SESSION_FILE_DIFF_AGGREGATE_CHAR_LIMIT = 100_000; +const SESSION_FILE_DIFF_CHAR_LIMIT = 50_000; +const SESSION_FILE_CONTENT_CHAR_LIMIT = 16_000; + +function isFileDiffDisplay(resultDisplay: unknown): resultDisplay is FileDiff { + if ( + typeof resultDisplay !== 'object' || + resultDisplay === null || + !('fileDiff' in resultDisplay) || + !('fileName' in resultDisplay) || + !('originalContent' in resultDisplay) || + !('newContent' in resultDisplay) + ) { + return false; + } + + const display = resultDisplay as Record; + const originalContent = display['originalContent']; + return ( + typeof display['fileDiff'] === 'string' && + typeof display['fileName'] === 'string' && + typeof display['newContent'] === 'string' && + (originalContent === null || typeof originalContent === 'string') + ); +} + +function stringLength(value: string | null | undefined): number { + return typeof value === 'string' ? value.length : 0; +} + +function truncateMiddleForSession(value: string, limit: number): string { + if (value.length <= limit) { + return value; + } + + const marker = `\n[... truncated for saved session preview; original length: ${value.length} characters ...]\n`; + const contentBudget = Math.max(0, limit - marker.length); + const headLength = Math.ceil(contentBudget * 0.6); + const tailLength = contentBudget - headLength; + + return ( + value.slice(0, headLength) + + marker + + (tailLength > 0 ? value.slice(value.length - tailLength) : '') + ); +} + +function buildSyntheticDiffPreview(display: FileDiff): string { + const originalLength = stringLength(display.originalContent); + return [ + `--- ${display.fileName}`, + `+++ ${display.fileName}`, + '@@ -1 +1 @@', + `-Full diff omitted from saved session history; original fileDiff length: ${display.fileDiff.length} characters.`, + `+Saved session preview only; originalContent length: ${originalLength} characters, newContent length: ${display.newContent.length} characters.`, + ].join('\n'); +} + +function sanitizeFileDiffForRecording(display: FileDiff): FileDiff { + const fileDiffLength = display.fileDiff.length; + const originalContentLength = stringLength(display.originalContent); + const newContentLength = display.newContent.length; + const aggregateLength = + fileDiffLength + originalContentLength + newContentLength; + + const fileDiffTruncated = fileDiffLength > SESSION_FILE_DIFF_CHAR_LIMIT; + const originalContentTruncated = + originalContentLength > SESSION_FILE_CONTENT_CHAR_LIMIT; + const newContentTruncated = + newContentLength > SESSION_FILE_CONTENT_CHAR_LIMIT; + + if ( + aggregateLength <= SESSION_FILE_DIFF_AGGREGATE_CHAR_LIMIT && + !fileDiffTruncated && + !originalContentTruncated && + !newContentTruncated + ) { + return display; + } + + return { + ...display, + fileDiff: fileDiffTruncated + ? buildSyntheticDiffPreview(display) + : display.fileDiff, + originalContent: + display.originalContent !== null && originalContentTruncated + ? truncateMiddleForSession( + display.originalContent, + SESSION_FILE_CONTENT_CHAR_LIMIT, + ) + : display.originalContent, + newContent: newContentTruncated + ? truncateMiddleForSession( + display.newContent, + SESSION_FILE_CONTENT_CHAR_LIMIT, + ) + : display.newContent, + truncatedForSession: true, + fileDiffLength, + originalContentLength, + newContentLength, + fileDiffTruncated, + originalContentTruncated, + newContentTruncated, + }; +} + +export function sanitizeToolCallResultForRecording< + T extends Partial, +>(toolCallResult: T): T { + const resultDisplay = toolCallResult.resultDisplay; + if (!isFileDiffDisplay(resultDisplay)) { + return toolCallResult; + } + + const sanitizedResultDisplay = sanitizeFileDiffForRecording(resultDisplay); + if (sanitizedResultDisplay === resultDisplay) { + return toolCallResult; + } + + return { + ...toolCallResult, + resultDisplay: sanitizedResultDisplay, + } as T; +} /** * Users who don't want the fast model silently generating titles can opt @@ -712,23 +838,27 @@ export class ChatRecordingService { }; if (toolCallResult) { + const recordingToolCallResult = + sanitizeToolCallResultForRecording(toolCallResult); + // special case for task executions - we don't want to record the tool calls if ( - typeof toolCallResult.resultDisplay === 'object' && - toolCallResult.resultDisplay !== null && - 'type' in toolCallResult.resultDisplay && - toolCallResult.resultDisplay.type === 'task_execution' + typeof recordingToolCallResult.resultDisplay === 'object' && + recordingToolCallResult.resultDisplay !== null && + 'type' in recordingToolCallResult.resultDisplay && + recordingToolCallResult.resultDisplay.type === 'task_execution' ) { - const taskResult = toolCallResult.resultDisplay as AgentResultDisplay; + const taskResult = + recordingToolCallResult.resultDisplay as AgentResultDisplay; record.toolCallResult = { - ...toolCallResult, + ...recordingToolCallResult, resultDisplay: { ...taskResult, toolCalls: [], }, }; } else { - record.toolCallResult = toolCallResult; + record.toolCallResult = recordingToolCallResult; } } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 65ec2fcef27..04f3a055cd4 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -561,6 +561,13 @@ export interface FileDiff { originalContent: string | null; newContent: string; diffStat?: DiffStat; + truncatedForSession?: boolean; + fileDiffLength?: number; + originalContentLength?: number; + newContentLength?: number; + fileDiffTruncated?: boolean; + originalContentTruncated?: boolean; + newContentTruncated?: boolean; } export interface DiffStat { From 3ed5888adaf73235e18bd907a702f1a82d6c97c1 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 6 May 2026 16:51:21 +0800 Subject: [PATCH 2/2] refactor(cli): share truncated diff preview text Use one helper for ACP replay and export fallback messages so truncated session preview wording cannot drift. Co-authored-by: Qwen-Coder --- .../session/emitters/ToolCallEmitter.ts | 22 ++--------------- packages/cli/src/ui/utils/export/normalize.ts | 22 ++--------------- .../cli/src/utils/truncatedDiffPreview.ts | 24 +++++++++++++++++++ 3 files changed, 28 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/utils/truncatedDiffPreview.ts diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index 106cf49b516..92f66ee4740 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -20,25 +20,7 @@ import type { } from '@agentclientprotocol/sdk'; import type { Part } from '@google/genai'; import { ToolNames, Kind } from '@qwen-code/qwen-code-core'; - -function buildTruncatedDiffReplayText( - display: Record, -): string { - const fileName = - typeof display['fileName'] === 'string' - ? display['fileName'] - : 'the edited file'; - const fileDiffLength = - typeof display['fileDiffLength'] === 'number' - ? ` Original fileDiff length: ${display['fileDiffLength']} chars.` - : ''; - - if (display['fileDiffTruncated'] === true) { - return `Full diff omitted from saved session history for ${fileName}.${fileDiffLength}`; - } - - return `Saved session preview only for ${fileName}; full original and new file contents are unavailable.`; -} +import { buildTruncatedDiffPreviewText } from '../../../utils/truncatedDiffPreview.js'; /** * Unified tool call event emitter. @@ -297,7 +279,7 @@ export class ToolCallEmitter extends BaseEmitter { type: 'content', content: { type: 'text', - text: buildTruncatedDiffReplayText(obj), + text: buildTruncatedDiffPreviewText(obj), }, }; } diff --git a/packages/cli/src/ui/utils/export/normalize.ts b/packages/cli/src/ui/utils/export/normalize.ts index ac32acd717d..44a90418b88 100644 --- a/packages/cli/src/ui/utils/export/normalize.ts +++ b/packages/cli/src/ui/utils/export/normalize.ts @@ -7,27 +7,9 @@ import type { Part } from '@google/genai'; import { ToolNames } from '@qwen-code/qwen-code-core'; import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core'; +import { buildTruncatedDiffPreviewText } from '../../../utils/truncatedDiffPreview.js'; import type { ExportMessage, ExportSessionData } from './types.js'; -function buildTruncatedDiffExportText( - display: Record, -): string { - const fileName = - typeof display['fileName'] === 'string' - ? display['fileName'] - : 'the edited file'; - const fileDiffLength = - typeof display['fileDiffLength'] === 'number' - ? ` Original fileDiff length: ${display['fileDiffLength']} chars.` - : ''; - - if (display['fileDiffTruncated'] === true) { - return `Full diff omitted from saved session history for ${fileName}.${fileDiffLength}`; - } - - return `Saved session preview only for ${fileName}; full original and new file contents are unavailable.`; -} - /** * Normalizes export session data by merging tool call information from tool_result records. * This ensures the SSOT contains complete tool call metadata. @@ -308,7 +290,7 @@ function extractDiffContent( type: 'content', content: { type: 'text', - text: buildTruncatedDiffExportText(display), + text: buildTruncatedDiffPreviewText(display), }, }, ]; diff --git a/packages/cli/src/utils/truncatedDiffPreview.ts b/packages/cli/src/utils/truncatedDiffPreview.ts new file mode 100644 index 00000000000..7b2510dd341 --- /dev/null +++ b/packages/cli/src/utils/truncatedDiffPreview.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function buildTruncatedDiffPreviewText( + display: Record, +): string { + const fileName = + typeof display['fileName'] === 'string' + ? display['fileName'] + : 'the edited file'; + const fileDiffLength = + typeof display['fileDiffLength'] === 'number' + ? ` Original fileDiff length: ${display['fileDiffLength']} chars.` + : ''; + + if (display['fileDiffTruncated'] === true) { + return `Full diff omitted from saved session history for ${fileName}.${fileDiffLength}`; + } + + return `Saved session preview only for ${fileName}; full original and new file contents are unavailable.`; +}