From fc5432d60714bf22f1d67f8974bba284759efe39 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:50:41 +0800 Subject: [PATCH] fix(tools): avoid false read-before-edit blocks --- kun/src/adapters/tool/read-tracker.test.ts | 84 ++++++++++++++++++++++ kun/src/adapters/tool/read-tracker.ts | 28 ++++++-- kun/tests/builtin-tools.test.ts | 34 +++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/kun/src/adapters/tool/read-tracker.test.ts b/kun/src/adapters/tool/read-tracker.test.ts index 55bf866e8..a137a3d2f 100644 --- a/kun/src/adapters/tool/read-tracker.test.ts +++ b/kun/src/adapters/tool/read-tracker.test.ts @@ -87,6 +87,90 @@ describe('ReadTracker cross-turn edits (#640)', () => { if (!verdict.ok) expect(verdict.message).toContain('was not present in the latest read output') }) + it('does not block edits based on a bounded read that omitted the target', () => { + const tracker = new ReadTracker(normalizeReadTrackerOptions(true)) + tracker.observeToolResult({ + ...readResult('turn_a', 'file.ts', 'const value = 42\n'), + output: { path: 'file.ts', content: 'const value = 42\n', truncated: true } + }) + + expect(tracker.validateBeforeTool({ + context: context('turn_b'), + call: editCall('file.ts', 'const other = 99') + })).toEqual({ ok: true }) + }) + + it('treats a line window as partial even when the read itself was not byte-truncated', () => { + const tracker = new ReadTracker(normalizeReadTrackerOptions(true)) + tracker.observeToolResult({ + ...readResult('turn_a', 'file.ts', 'first line\n'), + output: { + path: 'file.ts', + content: 'first line\n', + truncated: false, + start_line: 1, + end_line: 1, + total_lines: 4 + } + }) + + expect(tracker.validateBeforeTool({ + context: context('turn_b'), + call: editCall('file.ts', 'fourth line') + })).toEqual({ ok: true }) + }) + + it('treats a window that reaches EOF as partial when it omitted leading lines', () => { + const tracker = new ReadTracker(normalizeReadTrackerOptions(true)) + tracker.observeToolResult({ + ...readResult('turn_a', 'file.ts', 'third line\nfourth line\n'), + output: { + path: 'file.ts', + content: 'third line\nfourth line\n', + truncated: false, + start_line: 3, + end_line: 4, + total_lines: 4 + } + }) + + expect(tracker.validateBeforeTool({ + context: context('turn_b'), + call: editCall('file.ts', 'first line') + })).toEqual({ ok: true }) + }) + + it('keeps complete-snapshot validation when line metadata covers the whole file', () => { + const tracker = new ReadTracker(normalizeReadTrackerOptions(true)) + tracker.observeToolResult({ + ...readResult('turn_a', 'file.ts', 'first line\nsecond line'), + output: { + path: 'file.ts', + content: 'first line\nsecond line', + truncated: false, + start_line: 1, + end_line: 2, + total_lines: 2 + } + }) + + const verdict = tracker.validateBeforeTool({ + context: context('turn_b'), + call: editCall('file.ts', 'missing line') + }) + expect(verdict.ok).toBe(false) + }) + + it('uses the same newline and Unicode normalization as the edit matcher', () => { + const tracker = new ReadTracker(normalizeReadTrackerOptions(true)) + tracker.observeToolResult(readResult('turn_a', 'file.ts', 'const label = “ready”\r\n')) + + expect(tracker.validateBeforeTool({ + context: context('turn_b'), + call: editCall('file.ts', 'const label = "ready"') + })).toEqual({ ok: true }) + }) + it('allows a cross-turn multi-edit when every oldText fragment is present', () => { const tracker = new ReadTracker(normalizeReadTrackerOptions(true)) tracker.observeToolResult(readResult('turn_a', 'file.ts', 'alpha\nbeta\ngamma\n')) diff --git a/kun/src/adapters/tool/read-tracker.ts b/kun/src/adapters/tool/read-tracker.ts index 0661708d9..16df361ad 100644 --- a/kun/src/adapters/tool/read-tracker.ts +++ b/kun/src/adapters/tool/read-tracker.ts @@ -1,5 +1,6 @@ import { isAbsolute, relative, resolve } from 'node:path' import type { ToolCallLike, ToolHostContext } from '../../ports/tool-host.js' +import { normalizeForFuzzyMatch } from './edit-diff.js' export type ReadTrackerOptions = { enabled?: boolean @@ -13,7 +14,7 @@ type ReadRecord = { absolutePath: string relativePath?: string content?: string - truncated: boolean + partial: boolean turnId: string } @@ -36,7 +37,10 @@ export class ReadTracker { const absolutePath = normalizePath(rawPath, input.context.workspace) const record: ReadRecord = { absolutePath, - truncated: output.truncated === true, + // A bounded read, an explicit line window, or a first line that is too + // large is not a complete snapshot. The edit tool still validates the + // requested oldText against current bytes before writing. + partial: isPartialRead(output), turnId: input.context.turnId, ...(typeof output.relative_path === 'string' ? { relativePath: output.relative_path } : {}), ...(typeof output.content === 'string' ? { content: output.content } : {}) @@ -78,10 +82,10 @@ export class ReadTracker { // oldText fragments against the cached read content, and the edit's own // fuzzy matching runs against the current bytes on disk, so a stale SEARCH // string fails there with a clear error instead of corrupting the file. - if (!this.options.requireOldTextInRead) return { ok: true } + if (!this.options.requireOldTextInRead || record.partial) return { ok: true } const missing = oldTextFragments(input.call.arguments).filter((fragment) => { if (!fragment.trim()) return false - return !record.content || !record.content.includes(fragment) + return !record.content || !containsNormalized(record.content, fragment) }) if (missing.length === 0) return { ok: true } return { @@ -101,6 +105,22 @@ export class ReadTracker { } } +function isPartialRead(output: Record): boolean { + if (output.truncated === true || output.first_line_exceeds_limit === true) return true + const startLine = typeof output.start_line === 'number' ? output.start_line : undefined + const endLine = typeof output.end_line === 'number' ? output.end_line : undefined + const totalLines = typeof output.total_lines === 'number' ? output.total_lines : undefined + // A window can reach EOF while still omitting the beginning of the file. + // `end_line === total_lines` therefore does not by itself prove that the + // cached content is a complete snapshot. + return (startLine !== undefined && startLine > 1) || + (endLine !== undefined && totalLines !== undefined && endLine < totalLines) +} + +function containsNormalized(content: string, fragment: string): boolean { + return normalizeForFuzzyMatch(content).includes(normalizeForFuzzyMatch(fragment)) +} + export function normalizeReadTrackerOptions(input: boolean | ReadTrackerOptions | undefined): Required { if (input === true) return { enabled: true, requireOldTextInRead: true } if (input === false || input === undefined) return { enabled: false, requireOldTextInRead: true } diff --git a/kun/tests/builtin-tools.test.ts b/kun/tests/builtin-tools.test.ts index af055bf04..020035033 100644 --- a/kun/tests/builtin-tools.test.ts +++ b/kun/tests/builtin-tools.test.ts @@ -1296,6 +1296,40 @@ describe('Kun built-in tools', () => { expect(String(output.content)).toContain('Use offset=4 to continue') }) + it('allows an edit after a read window reaches EOF but omits leading lines', async () => { + await writeFile(join(workspace, 'paged-edit.txt'), 'one\ntwo\nthree\nfour', 'utf8') + const guardedHost = new LocalToolHost({ + tools: buildCodingBuiltinLocalTools(), + readTracker: true + }) + const context = buildContext(workspace) + + const read = await guardedHost.execute( + { + callId: 'call_read_paged_edit', + toolName: 'read', + arguments: { path: 'paged-edit.txt', offset: 3 } + }, + context + ) + expect(read.item).toMatchObject({ + kind: 'tool_result', + isError: false, + output: { start_line: 3, end_line: 4, total_lines: 4, truncated: false } + }) + + const edit = await guardedHost.execute( + { + callId: 'call_edit_paged_edit', + toolName: 'edit', + arguments: { path: 'paged-edit.txt', oldText: 'one', newText: 'ONE' } + }, + context + ) + expect(edit.item).toMatchObject({ kind: 'tool_result', isError: false }) + await expect(readFile(join(workspace, 'paged-edit.txt'), 'utf8')).resolves.toContain('ONE') + }) + it('reads supported images with pi-style structured image metadata', async () => { const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,