Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions kun/src/adapters/tool/read-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
28 changes: 24 additions & 4 deletions kun/src/adapters/tool/read-tracker.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +14,7 @@ type ReadRecord = {
absolutePath: string
relativePath?: string
content?: string
truncated: boolean
partial: boolean
turnId: string
}

Expand All @@ -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 } : {})
Expand Down Expand Up @@ -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 {
Expand All @@ -101,6 +105,22 @@ export class ReadTracker {
}
}

function isPartialRead(output: Record<string, unknown>): 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<ReadTrackerOptions> {
if (input === true) return { enabled: true, requireOldTextInRead: true }
if (input === false || input === undefined) return { enabled: false, requireOldTextInRead: true }
Expand Down
34 changes: 34 additions & 0 deletions kun/tests/builtin-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading