From 1ac463dd346bf68f25cf2e2eca12d2e28820df85 Mon Sep 17 00:00:00 2001 From: tjdoomer Date: Thu, 2 Apr 2026 17:50:38 +1100 Subject: [PATCH 1/2] docs: add roadmap for diff preview tool Dry-run edit returning unified diff for model self-review before applying changes. Uses existing diff package dependency. --- docs/roadmap/11-diff-preview.md | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/roadmap/11-diff-preview.md diff --git a/docs/roadmap/11-diff-preview.md b/docs/roadmap/11-diff-preview.md new file mode 100644 index 00000000000..e4a538e0ea6 --- /dev/null +++ b/docs/roadmap/11-diff-preview.md @@ -0,0 +1,89 @@ +# Diff Preview Tool + +## Branch: `feature/diff-preview` + +## Problem + +The edit tool applies changes immediately. If the model makes a mistake, it has +to undo (currently manual via `/checkpoint restore`). There's no way for the +model to preview what a change would look like before committing to it. + +## What it is + +A dry-run edit tool that returns a unified diff without modifying any files. +The model can self-review the diff and decide whether to proceed. + +## Tool declaration + +```typescript +{ + name: 'diff_preview', + description: 'Preview what an edit would look like as a unified diff ' + + 'WITHOUT applying it. Use this to verify your changes before ' + + 'committing them with the edit tool. Returns a unified diff with ' + + 'context lines.', + parameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute path to the file.', + }, + old_string: { + type: 'string', + description: 'The exact text to be replaced.', + }, + new_string: { + type: 'string', + description: 'The replacement text.', + }, + context_lines: { + type: 'number', + description: 'Number of context lines around changes (default 3).', + }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, +} +``` + +## Output + +```diff +--- a/packages/core/src/core/anthropicContentGenerator.ts ++++ b/packages/core/src/core/anthropicContentGenerator.ts +@@ -286,4 +286,8 @@ + async countTokens(request: CountTokensParameters): Promise { +- // Anthropic doesn't have a dedicated token counting endpoint +- // Rough approximation: 1 token ≈ 4 characters +- const content = JSON.stringify(request.contents); +- const totalTokens = Math.ceil(content.length / 4); ++ const encoder = encoding_for_model('cl100k_base'); ++ const content = JSON.stringify(request.contents); ++ const totalTokens = encoder.encode(content).length; ++ encoder.free(); + + return { totalTokens }; +``` + +## Implementation + +Reuse the existing `diff` package (already in dependencies) to generate +unified diffs. The tool: + +1. Read the file +2. Find `old_string` (same validation as edit tool) +3. Compute the replacement in memory (don't write) +4. Generate unified diff with `createPatch()` from the `diff` package +5. Return the diff string + +~30 lines of implementation code. + +## Files to create +- `packages/core/src/tools/diffPreview.ts` +- `packages/core/src/tools/diffPreview.test.ts` + +## Files to modify +- `packages/core/src/config/config.ts` — register tool + +## Effort: ~2 hours From ccb196acb936bce60b8927f7400193d62d79ae04 Mon Sep 17 00:00:00 2001 From: tjdoomer Date: Thu, 2 Apr 2026 21:40:47 +1100 Subject: [PATCH 2/2] feat: add diff preview tool for dry-run edit verification Returns a unified diff of what an edit would look like without applying it. Validates old_string uniqueness. Uses the diff package. --- packages/core/src/config/config.ts | 2 + packages/core/src/tools/diffPreview.ts | 123 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 packages/core/src/tools/diffPreview.ts diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7d41caa0fe0..97be5082274 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -31,6 +31,7 @@ import { TodoWriteTool } from '../tools/todoWrite.js'; import { WebSearchTool } from '../tools/web-search.js'; import { SubAgentTool } from '../tools/subAgentTool.js'; import { ThinkTool } from '../tools/think.js'; +import { DiffPreviewTool } from '../tools/diffPreview.js'; import { DeltaClient } from '../core/client.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; @@ -918,6 +919,7 @@ export class Config { } registerCoreTool(SubAgentTool, this); registerCoreTool(ThinkTool); + registerCoreTool(DiffPreviewTool); await registry.discoverAllTools(); return registry; diff --git a/packages/core/src/tools/diffPreview.ts b/packages/core/src/tools/diffPreview.ts new file mode 100644 index 00000000000..789806522bc --- /dev/null +++ b/packages/core/src/tools/diffPreview.ts @@ -0,0 +1,123 @@ +/** + * Diff preview tool — dry-run an edit and return a unified diff without + * applying it. Lets the model self-review changes before committing. + * + * Uses the `diff` package (already a dependency) to generate unified diffs. + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolResult, +} from './tools.js'; +import { FunctionDeclaration } from '@google/genai'; +import * as fs from 'node:fs/promises'; +import { createPatch } from 'diff'; + +interface DiffPreviewParams { + file_path: string; + old_string: string; + new_string: string; + context_lines?: number; +} + +const diffPreviewSchemaData: FunctionDeclaration = { + name: 'diff_preview', + description: + 'Preview what an edit would look like as a unified diff WITHOUT applying it. Use this to verify your changes before committing them with the edit tool. Returns a unified diff with context lines.', + parametersJsonSchema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute path to the file.', + }, + old_string: { + type: 'string', + description: 'The exact text to be replaced.', + }, + new_string: { + type: 'string', + description: 'The replacement text.', + }, + context_lines: { + type: 'number', + description: 'Number of context lines around changes (default 3).', + }, + }, + required: ['file_path', 'old_string', 'new_string'], + $schema: 'http://json-schema.org/draft-07/schema#', + }, +}; + +class DiffPreviewInvocation extends BaseToolInvocation { + getDescription(): string { + return this.params.file_path; + } + + async execute(): Promise { + const { file_path, old_string, new_string, context_lines } = this.params; + + let content: string; + try { + content = await fs.readFile(file_path, 'utf-8'); + } catch { + return { + llmContent: [{ text: `Error: file not found: ${file_path}` }], + returnDisplay: `Error: file not found: ${file_path}`, + error: { message: `File not found: ${file_path}` }, + }; + } + + // Verify old_string exists in the file + const idx = content.indexOf(old_string); + if (idx === -1) { + return { + llmContent: [{ text: 'Error: old_string not found in file.' }], + returnDisplay: 'Error: old_string not found in file.', + error: { message: 'old_string not found in file' }, + }; + } + + // Check for ambiguous matches (multiple occurrences) + const secondIdx = content.indexOf(old_string, idx + 1); + if (secondIdx !== -1) { + return { + llmContent: [{ text: 'Error: old_string matches multiple locations. Provide more context to make it unique.' }], + returnDisplay: 'Error: old_string matches multiple locations.', + error: { message: 'old_string matches multiple locations' }, + }; + } + + // Apply the edit in memory + const newContent = content.replace(old_string, new_string); + + // Generate unified diff + const ctx = context_lines ?? 3; + const patch = createPatch(file_path, content, newContent, '', '', { context: ctx }); + + return { + llmContent: [{ text: patch }], + returnDisplay: patch, + }; + } +} + +export class DiffPreviewTool extends BaseDeclarativeTool { + static readonly Name: string = diffPreviewSchemaData.name!; + + constructor() { + super( + DiffPreviewTool.Name, + 'Diff Preview', + diffPreviewSchemaData.description!, + Kind.Read, + diffPreviewSchemaData.parametersJsonSchema, + ); + } + + protected createInvocation(params: DiffPreviewParams) { + return new DiffPreviewInvocation(params); + } +}