Repository navigation
perf(edit): bound the post-edit diff and unified patch #284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8d5c54b
perf(edit): bound the post-edit diff and unified patch
TrentChou 96b0521
fix(tui): render an omitted edit diff as a summary block
TrentChou 6da4254
fix(agent-tools): keep Compatible hooks working when the edit patch i…
TrentChou 6bca1d9
fix(edit): report why the unified patch was omitted
TrentChou b47ff4f
fix(edit): raise the diff bound so ordinary rewrites keep their diff
TrentChou 20641c0
fix(agent-tools): send an empty structured patch when the edit patch …
TrentChou File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
185 changes: 185 additions & 0 deletions
185
packages/agent-tools/src/desktop/edit-diff-bounds.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
|
|
||
| import { readPluginHookCompatibleToolResponse } from '../plugin-hooks/vendor-tool-response.js'; | ||
| import { LocalEditTool } from './local-pi-tools.js'; | ||
|
|
||
| interface CompatibleHunk { | ||
| readonly oldStart: number; | ||
| readonly oldLines: number; | ||
| readonly newStart: number; | ||
| readonly newLines: number; | ||
| readonly lines: readonly string[]; | ||
| } | ||
|
|
||
| const context = { sessionId: 'edit-diff-bounds-session', turnId: 'edit-diff-bounds-turn' }; | ||
|
|
||
| // Myers costs O((N+M)·D) in the length D of the edit script, so a whole-file | ||
| // rewrite is quadratic in the number of changed lines. Rewriting every line of | ||
| // this file took 120-139 s unbounded (the tool diffed the same input twice) and | ||
| // produced a ~1.3 MB diff no renderer displays; bounded it settles in ~220 ms. | ||
| // Vitest's default 5 s timeout therefore also guards the bound: if it is ever | ||
| // removed, the whole-file case stops finishing in time. | ||
| const wholeFileRewriteLines = 20_000; | ||
|
|
||
| // Replacing a line costs 2 edits, so DIFF_MAX_EDIT_LENGTH (2000) admits a full | ||
| // rewrite of a file this long and omits anything longer. | ||
| const boundedRewriteLines = 1_000; | ||
|
|
||
| function body(lines: number, render: (index: number) => string): string { | ||
| return `${Array.from({ length: lines }, (_, index) => render(index)).join('\n')}\n`; | ||
| } | ||
|
|
||
| describe('edit diff bounds', () => { | ||
| let directory: string; | ||
| let file: string; | ||
|
|
||
| beforeEach(async () => { | ||
| directory = await mkdtemp(join(tmpdir(), 'edit-diff-bounds-')); | ||
| file = join(directory, 'subject.ts'); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await rm(directory, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('keeps the diff and patch for an ordinary edit', async () => { | ||
| await writeFile(file, 'const value = 1;\nconst other = 2;\n'); | ||
|
|
||
| const result = await new LocalEditTool(directory).execute(context, { | ||
| file_path: 'subject.ts', | ||
| old_string: 'const value = 1;', | ||
| new_string: 'const value = 42;', | ||
| }); | ||
|
|
||
| expect(await readFile(file, 'utf8')).toBe('const value = 42;\nconst other = 2;\n'); | ||
| expect(result.details?.diffOmitted).toBeUndefined(); | ||
| expect(result.details?.patchOmitted).toBeUndefined(); | ||
| expect(result.details?.diff).toContain('const value = 42;'); | ||
| expect(result.details?.patch).toContain('@@'); | ||
| expect(result.details?.patch).toContain('+const value = 42;'); | ||
| }); | ||
|
|
||
| it('keeps the diff for a large but bounded block replacement', async () => { | ||
| const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); | ||
| await writeFile(file, original); | ||
| // 400 replaced lines cost 800 edits, which stays under the bound even | ||
| // though the surrounding file is large: the bound tracks changed lines, | ||
| // not file size. | ||
| const oldBlock = body(400, (index) => `const value${index} = ${index};`); | ||
| const newBlock = body(400, (index) => `const value${index} = ${index + 1};`); | ||
|
|
||
| const result = await new LocalEditTool(directory).execute(context, { | ||
| file_path: 'subject.ts', | ||
| old_string: oldBlock, | ||
| new_string: newBlock, | ||
| }); | ||
|
|
||
| expect(await readFile(file, 'utf8')).toBe(`${newBlock}${original.slice(oldBlock.length)}`); | ||
| expect(result.details?.diffOmitted).toBeUndefined(); | ||
| expect(result.details?.patch).toContain('@@'); | ||
| }); | ||
|
|
||
| // Rewriting a few hundred lines is an ordinary edit, not the pathological | ||
| // case this bound targets: a reported 501-line whole-file replacement lost | ||
| // its diff under the first bound this test pins. | ||
| it('keeps the diff for a whole-file rewrite that fits the bound', async () => { | ||
| const original = body(boundedRewriteLines, (index) => `const value${index} = ${index};`); | ||
| const rewritten = body(boundedRewriteLines, (index) => `let renamed${index} = ${index * 2};`); | ||
| await writeFile(file, original); | ||
|
|
||
| const result = await new LocalEditTool(directory).execute(context, { | ||
| file_path: 'subject.ts', | ||
| old_string: original, | ||
| new_string: rewritten, | ||
| }); | ||
|
|
||
| expect(await readFile(file, 'utf8')).toBe(rewritten); | ||
| expect(result.details?.diffOmitted).toBeUndefined(); | ||
| expect(result.details?.patchOmitted).toBeUndefined(); | ||
| expect(result.details?.patch).toContain('+let renamed0 = 0;'); | ||
| }); | ||
|
|
||
| it('omits the diff for a whole-file rewrite but still writes the file', async () => { | ||
| const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); | ||
| const rewritten = body(wholeFileRewriteLines, (index) => `let renamed${index} = ${index * 2};`); | ||
| await writeFile(file, original); | ||
|
|
||
| const result = await new LocalEditTool(directory).execute(context, { | ||
| file_path: 'subject.ts', | ||
| old_string: original, | ||
| new_string: rewritten, | ||
| }); | ||
|
|
||
| // The edit itself must be unaffected: the diff is a receipt computed after | ||
| // the write, so giving it up never changes what lands on disk. | ||
| expect(await readFile(file, 'utf8')).toBe(rewritten); | ||
| expect(result.isError).toBeFalsy(); | ||
|
|
||
| expect(result.details?.diffOmitted).toBe('too_many_changes'); | ||
| expect(result.details?.patchOmitted).toBe('too_many_changes'); | ||
| expect(result.details?.patch).toBeUndefined(); | ||
| expect(result.details?.diff).toContain('diff omitted'); | ||
| }); | ||
|
|
||
| // A Compatible PostToolUse handler is skipped with HOOK_INVALID_INPUT when | ||
| // `structuredPatch` is missing, so dropping `details.patch` silently disabled | ||
| // every such hook on exactly the edits this bound targets. | ||
| it('still hands the Compatible hook a structured patch when the patch is omitted', async () => { | ||
| const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); | ||
| const rewritten = body(wholeFileRewriteLines, (index) => `let renamed${index} = ${index * 2};`); | ||
| await writeFile(file, original); | ||
|
|
||
| const result = await new LocalEditTool(directory).execute(context, { | ||
| file_path: 'subject.ts', | ||
| old_string: original, | ||
| new_string: rewritten, | ||
| }); | ||
|
|
||
| expect(result.details?.patch).toBeUndefined(); | ||
| const response = readPluginHookCompatibleToolResponse(result); | ||
| expect(response).toBeDefined(); | ||
| expect(response?.originalFile).toBe(original); | ||
| const hunks = response?.structuredPatch as readonly CompatibleHunk[] | undefined; | ||
| expect(hunks).toHaveLength(1); | ||
| const hunk = hunks?.[0]; | ||
| expect(hunk?.oldStart).toBe(1); | ||
| expect(hunk?.newStart).toBe(1); | ||
| expect(hunk?.oldLines).toBe(wholeFileRewriteLines); | ||
| expect(hunk?.newLines).toBe(wholeFileRewriteLines); | ||
| // Every old line is removed and every new line added, in that order. | ||
| expect(hunk?.lines).toHaveLength(wholeFileRewriteLines * 2); | ||
| expect(hunk?.lines[0]).toBe('-const value0 = 0;'); | ||
| expect(hunk?.lines[wholeFileRewriteLines - 1]).toBe( | ||
| `-const value${wholeFileRewriteLines - 1} = ${wholeFileRewriteLines - 1};`, | ||
| ); | ||
| expect(hunk?.lines[wholeFileRewriteLines]).toBe('+let renamed0 = 0;'); | ||
| }); | ||
|
|
||
| it('marks a missing trailing newline in the synthesized hunk', async () => { | ||
| const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); | ||
| const rewritten = body( | ||
| wholeFileRewriteLines, | ||
| (index) => `let renamed${index} = ${index * 2};`, | ||
| ).slice(0, -1); | ||
| await writeFile(file, original); | ||
|
|
||
| const result = await new LocalEditTool(directory).execute(context, { | ||
| file_path: 'subject.ts', | ||
| old_string: original, | ||
| new_string: rewritten, | ||
| }); | ||
|
|
||
| expect(await readFile(file, 'utf8')).toBe(rewritten); | ||
| const hunks = readPluginHookCompatibleToolResponse(result)?.structuredPatch as | ||
| | readonly CompatibleHunk[] | ||
| | undefined; | ||
| // jsdiff emits the marker after the side that lacks the newline; only the | ||
| // new side does here, so it lands last. | ||
| expect(hunks?.[0]?.lines.at(-1)).toBe('\\ No newline at end of file'); | ||
| expect(hunks?.[0]?.lines.filter((line) => line.startsWith('\\'))).toHaveLength(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Trim unchanged content before synthesizing the hook patch
This fallback copies every unchanged line into both sides of the hunk, but PluginHookRunner rejects serialized inputs above 1 MiB. As a result, a successful edit can still stop invoking a previously working Compatible/Claude PostToolUse handler.
Reproduced at this HEAD through LocalEditTool -> buildCompatiblePostToolResponse -> PluginHookRunner: replacing the first 1,001 short lines in a 500,789-byte file with an unchanged suffix produces a 1,611,454-byte compatibility response. The handler is skipped with HOOK_INVALID_INPUT. With the previous minimal unified patch for the same edit, the response is 553,836 bytes and the handler executes successfully. The new tests check that structuredPatch exists, so they do not catch the downstream size rejection.
Please trim common line prefixes/suffixes and adjust the hunk positions before building this fallback. That retains linear complexity while avoiding expansion from unrelated content. Add a regression that runs a matching PostToolUse handler for a bounded-out block edit inside a larger, mostly unchanged file.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
解决了,解决方案参考了一下其他agent 里的实现方案:
根据当前具体的 edit 工具的 hook 的协议,edit 工具的 hook 里的 Patch 信息需要存在,但是当改大量行的时候 Patch 会超大
2.
在超大的情况下,对 hook 预期返回 [],对 diff 返回 too many changes
3.
所以不能走「干脆不给这个字段」:structuredPatch 是 required,字段一缺 hook 压根不会被选上,edit 也不再改名成 Edit,第三方写的 "matcher": "Edit" 直接不匹配,比原来的 bug 更糟。塞自定义字段说明原因也没用,zod 会在第三方看到之前把未知字段 strip 掉