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
41 changes: 41 additions & 0 deletions packages/ai-claude-compat/src/edit-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,47 @@ test('editFileTool: rejects an edit against content changed on disk since the re
}
});

test('editFileTool: a non-UTF-8-byte file survives Read → Edit — no permanent stale-read loop (issue #177)', async () => {
const dir = await tempDir();
try {
// 0xE9 (Latin-1 'é') is not valid standalone UTF-8: readFile('utf8') decodes it to U+FFFD. Read
// must fingerprint the same decode Edit compares, or the two hashes never agree and the pair locks
// into a permanent "modified since you read it" loop.
const file = join(dir.path, 'latin1.txt');
await writeFile(file, Buffer.from([0x68, 0x69, 0x20, 0xe9, 0x20, 0x62, 0x79, 0x65, 0x0a])); // "hi é bye\n"
const t = tools(dir.path);
await run(t.read, { path: 'latin1.txt' });
const out = await run<
{ path: string; oldString: string; newString: string },
{ replacements: number }
>(t.edit, { path: 'latin1.txt', oldString: 'hi', newString: 'yo' });
assert.equal(
out.replacements,
1,
'edit lands — read-before-edit hash matched despite non-UTF-8 bytes',
);
} finally {
await dir.cleanup();
}
});

test('editFileTool: a genuine external modification after Read is still rejected on a non-UTF-8 file (issue #177)', async () => {
const dir = await tempDir();
try {
const file = join(dir.path, 'latin1.txt');
await writeFile(file, Buffer.from([0x68, 0x69, 0x20, 0xe9, 0x0a])); // "hi é\n"
const t = tools(dir.path);
await run(t.read, { path: 'latin1.txt' });
await writeFile(file, Buffer.from([0x62, 0x79, 0x65, 0x20, 0xe9, 0x0a])); // externally → "bye é\n"
await assert.rejects(
() => run(t.edit, { path: 'latin1.txt', oldString: 'bye', newString: 'x' }),
/modified since you read it/,
);
} finally {
await dir.cleanup();
}
});

test('editFileTool: an immediate follow-up edit needs no re-read (issue #104)', async () => {
const dir = await tempDir();
try {
Expand Down
12 changes: 7 additions & 5 deletions packages/ai-claude-compat/src/fs-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
// content. Output is `cat -n` numbered, windowed to a default of 2000 lines.

import { createReadStream } from 'node:fs';
import { mkdir, stat } from 'node:fs/promises';
import { mkdir, readFile, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { createInterface } from 'node:readline';
import { type Tool, tool } from 'ai';
import { z } from 'zod';
import { atomicWriteFile } from './atomic-write.ts';
import { type FileStateTracker, hashContent, hashFile } from './file-state.ts';
import { type FileStateTracker, hashContent } from './file-state.ts';
import { resolveInside } from './safe-path.ts';

export type ToolInit = {
Expand Down Expand Up @@ -78,9 +78,11 @@ export function readFileTool(init: FileToolInit): Tool<ReadFileInput, ReadFileOu
const offset = input.offset ?? 1;
const limit = input.limit ?? DEFAULT_READ_LINES;
const window = await readNumberedWindow(safe, offset, limit);
// Fingerprint the WHOLE file (hashFile streams it) even for a windowed read, so a later Edit
// can tell whether the on-disk content changed since this read.
init.fileState.record(safe, await hashFile(safe), 'read');
// Fingerprint the WHOLE file even for a windowed read, so a later Edit can tell whether the
// on-disk content changed since this read. Hash the UTF-8 DECODE (not raw bytes) so it matches
// what Edit compares (`hashContent(readFile(utf8))`) — otherwise a non-UTF-8-roundtrippable file
// never agrees between Read and Edit and locks into a permanent stale-read loop (issue #177).
init.fileState.record(safe, hashContent(await readFile(safe, 'utf8')), 'read');
return { content: renderReadBody(input.path, window, offset) };
},
// The model sees the file content as raw text, not a JSON-escaped `{"content":"…"}` envelope
Expand Down
Loading