Skip to content

Commit 9588313

Browse files
committed
feat: enhance file versioning and snippet handling in edit and write tools
1 parent 55bb8b4 commit 9588313

4 files changed

Lines changed: 268 additions & 19 deletions

File tree

src/common/state.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type FileState = {
77
filePath: string;
88
content: string;
99
timestamp: number;
10+
version?: number;
1011
offset?: number;
1112
limit?: number;
1213
isPartialView?: boolean;
@@ -20,11 +21,13 @@ export type FileSnippet = {
2021
startLine: number;
2122
endLine: number;
2223
preview: string;
24+
fileVersion: number;
2325
};
2426

2527
const fileStatesBySession = new Map<string, Map<string, FileState>>();
2628
const snippetsBySession = new Map<string, Map<string, FileSnippet>>();
2729
const snippetCountersBySession = new Map<string, number>();
30+
const fileVersionsBySession = new Map<string, Map<string, number>>();
2831

2932
export function normalizeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string {
3033
const nativePath = normalizeNativeFilePath(filePath, platform);
@@ -57,7 +60,11 @@ function isGitBashAbsolutePath(filePath: string): boolean {
5760
return /^\/[A-Za-z](?:\/|$)/.test(filePath) || /^\/cygdrive\/[A-Za-z](?:\/|$)/.test(filePath);
5861
}
5962

60-
export function recordFileState(sessionId: string, state: FileState): void {
63+
export function recordFileState(
64+
sessionId: string,
65+
state: FileState,
66+
options: { incrementVersion?: boolean } = {}
67+
): void {
6168
if (!sessionId || !state.filePath) {
6269
return;
6370
}
@@ -69,9 +76,13 @@ export function recordFileState(sessionId: string, state: FileState): void {
6976
}
7077

7178
const normalizedPath = normalizeFilePath(state.filePath);
79+
const currentVersion = getFileVersion(sessionId, normalizedPath);
80+
const nextVersion = options.incrementVersion ? currentVersion + 1 : currentVersion;
81+
setFileVersion(sessionId, normalizedPath, nextVersion);
7282
sessionState.set(normalizedPath, {
7383
...state,
7484
filePath: normalizedPath,
85+
version: nextVersion,
7586
});
7687
}
7788

@@ -108,6 +119,22 @@ export function wasFileRead(sessionId: string, filePath: string): boolean {
108119
return getFileState(sessionId, filePath) !== null;
109120
}
110121

122+
export function getFileVersion(sessionId: string, filePath: string): number {
123+
if (!sessionId || !filePath) {
124+
return 0;
125+
}
126+
return fileVersionsBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? 0;
127+
}
128+
129+
function setFileVersion(sessionId: string, filePath: string, version: number): void {
130+
let sessionVersions = fileVersionsBySession.get(sessionId);
131+
if (!sessionVersions) {
132+
sessionVersions = new Map<string, number>();
133+
fileVersionsBySession.set(sessionId, sessionVersions);
134+
}
135+
sessionVersions.set(normalizeFilePath(filePath), version);
136+
}
137+
111138
export function isFullFileView(state: FileState | null): boolean {
112139
return Boolean(
113140
state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined"
@@ -134,6 +161,7 @@ export function createSnippet(
134161
startLine,
135162
endLine,
136163
preview,
164+
fileVersion: getFileVersion(sessionId, filePath),
137165
};
138166

139167
let snippets = snippetsBySession.get(sessionId);
@@ -151,3 +179,7 @@ export function getSnippet(sessionId: string, snippetId: string): FileSnippet |
151179
}
152180
return snippetsBySession.get(sessionId)?.get(snippetId) ?? null;
153181
}
182+
183+
export function hasSnippetOutdatedFileVersion(sessionId: string, snippet: FileSnippet): boolean {
184+
return getFileVersion(sessionId, snippet.filePath) > snippet.fileVersion;
185+
}

src/tests/tool-handlers.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,184 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn
8888
assert.match(candidates[0]?.preview ?? "", /city/);
8989
});
9090

91+
test("Edit returns closest matches only above threshold with surrounding context", async () => {
92+
const workspace = createTempWorkspace();
93+
const filePath = path.join(workspace, "closest.ts");
94+
fs.writeFileSync(
95+
filePath,
96+
[
97+
"const before = true;",
98+
"function computeSubtotal(value: number) {",
99+
" return value;",
100+
"}",
101+
"const after = true;",
102+
].join("\n"),
103+
"utf8"
104+
);
105+
106+
const sessionId = "closest-match-context";
107+
await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace));
108+
109+
const closeResult = await handleEditTool(
110+
{
111+
file_path: filePath,
112+
old_string: "function computeTotal(value: number) {",
113+
new_string: "function computeTotal(input: number) {",
114+
},
115+
createContext(sessionId, workspace)
116+
);
117+
118+
assert.equal(closeResult.ok, false);
119+
assert.equal(closeResult.error, "old_string not found in file.");
120+
const closestMatch = closeResult.metadata?.closest_match as
121+
| { snippet_id?: string; start_line?: number; end_line?: number; similarity?: number; preview?: string }
122+
| undefined;
123+
assert.ok(closestMatch?.snippet_id);
124+
assert.equal(closestMatch.start_line, 1);
125+
assert.equal(closestMatch.end_line, 4);
126+
assert.ok((closestMatch.similarity ?? 0) >= 0.8);
127+
assert.match(closestMatch.preview ?? "", /const before = true/);
128+
assert.match(closestMatch.preview ?? "", /return value/);
129+
130+
const lowResult = await handleEditTool(
131+
{
132+
file_path: filePath,
133+
old_string: 'query: string = Field(description="search query")',
134+
new_string: "query: string",
135+
},
136+
createContext(sessionId, workspace)
137+
);
138+
139+
assert.equal(lowResult.ok, false);
140+
assert.equal(lowResult.error, "old_string not found in file.");
141+
assert.equal(lowResult.metadata?.closest_match, undefined);
142+
143+
const partialRead = await handleReadTool(
144+
{ file_path: filePath, offset: 2, limit: 2 },
145+
createContext(sessionId, workspace)
146+
);
147+
const snippet = (partialRead.metadata?.snippet ?? null) as { id: string } | null;
148+
assert.ok(snippet);
149+
150+
const scopedCloseResult = await handleEditTool(
151+
{
152+
snippet_id: snippet.id,
153+
old_string: "function computeTotal(value: number) {",
154+
new_string: "function computeTotal(input: number) {",
155+
},
156+
createContext(sessionId, workspace)
157+
);
158+
159+
assert.equal(scopedCloseResult.ok, false);
160+
const scopedClosestMatch = scopedCloseResult.metadata?.closest_match as
161+
| { start_line?: number; end_line?: number; preview?: string }
162+
| undefined;
163+
assert.equal(scopedClosestMatch?.start_line, 2);
164+
assert.equal(scopedClosestMatch?.end_line, 3);
165+
assert.doesNotMatch(scopedClosestMatch?.preview ?? "", /const before = true/);
166+
});
167+
168+
test("Edit allows outdated snippet matches but reports outdated snippet when no match is found", async () => {
169+
const workspace = createTempWorkspace();
170+
const filePath = path.join(workspace, "snippet-outdated.txt");
171+
fs.writeFileSync(filePath, ["alpha = 1", "beta = 1", "gamma = 1"].join("\n"), "utf8");
172+
173+
const sessionId = "outdated-snippet-miss";
174+
const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace));
175+
const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null;
176+
assert.ok(snippet);
177+
178+
const firstEdit = await handleEditTool(
179+
{
180+
snippet_id: snippet.id,
181+
old_string: "alpha = 1",
182+
new_string: "alpha = 2",
183+
},
184+
createContext(sessionId, workspace)
185+
);
186+
assert.equal(firstEdit.ok, true);
187+
188+
const secondEdit = await handleEditTool(
189+
{
190+
snippet_id: snippet.id,
191+
old_string: "beta = 1",
192+
new_string: "beta = 2",
193+
},
194+
createContext(sessionId, workspace)
195+
);
196+
assert.equal(secondEdit.ok, true);
197+
assert.equal(fs.readFileSync(filePath, "utf8"), ["alpha = 2", "beta = 2", "gamma = 1"].join("\n"));
198+
199+
const missingEdit = await handleEditTool(
200+
{
201+
snippet_id: snippet.id,
202+
old_string: "delta = 1",
203+
new_string: "delta = 2",
204+
},
205+
createContext(sessionId, workspace)
206+
);
207+
208+
assert.equal(missingEdit.ok, false);
209+
assert.equal(
210+
missingEdit.error,
211+
"old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing."
212+
);
213+
const outdatedScope = (missingEdit.metadata?.scope ?? {}) as { snippet_id?: string };
214+
assert.equal(outdatedScope.snippet_id, snippet.id);
215+
216+
const freshRead = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace));
217+
const freshSnippet = (freshRead.metadata?.snippet ?? null) as { id: string } | null;
218+
assert.ok(freshSnippet);
219+
220+
const freshMissingEdit = await handleEditTool(
221+
{
222+
snippet_id: freshSnippet.id,
223+
old_string: "delta = 1",
224+
new_string: "delta = 2",
225+
},
226+
createContext(sessionId, workspace)
227+
);
228+
229+
assert.equal(freshMissingEdit.ok, false);
230+
assert.equal(freshMissingEdit.error, "old_string not found in file.");
231+
});
232+
233+
test("Edit reports outdated snippet when a later Write changes the file and snippet matching fails", async () => {
234+
const workspace = createTempWorkspace();
235+
const filePath = path.join(workspace, "write-outdated.txt");
236+
fs.writeFileSync(filePath, ["alpha = 1", "beta = 1"].join("\n"), "utf8");
237+
238+
const sessionId = "write-outdated-snippet";
239+
const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace));
240+
const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null;
241+
assert.ok(snippet);
242+
243+
const writeResult = await handleWriteTool(
244+
{
245+
file_path: filePath,
246+
content: ["alpha = 2", "gamma = 2"].join("\n"),
247+
},
248+
createContext(sessionId, workspace)
249+
);
250+
251+
assert.equal(writeResult.ok, true);
252+
253+
const editResult = await handleEditTool(
254+
{
255+
snippet_id: snippet.id,
256+
old_string: "beta = 1",
257+
new_string: "beta = 2",
258+
},
259+
createContext(sessionId, workspace)
260+
);
261+
262+
assert.equal(editResult.ok, false);
263+
assert.equal(
264+
editResult.error,
265+
"old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing."
266+
);
267+
});
268+
91269
test("replace_all requires expected_occurrences for broad short-fragment replacements", async () => {
92270
const workspace = createTempWorkspace();
93271
const filePath = path.join(workspace, "openapi.yaml");

src/tools/edit-handler.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createSnippet,
1414
getFileState,
1515
getSnippet,
16+
hasSnippetOutdatedFileVersion,
1617
isAbsoluteFilePath,
1718
isFullFileView,
1819
normalizeFilePath,
@@ -22,7 +23,10 @@ import {
2223
const MAX_CANDIDATE_COUNT = 5;
2324
const REPLACE_ALL_MATCH_THRESHOLD = 5;
2425
const SHORT_REPLACE_ALL_LENGTH = 40;
25-
const MIN_FUZZY_SCORE = 0.45;
26+
const MIN_FUZZY_SCORE = 0.8;
27+
const CLOSEST_MATCH_CONTEXT_LINES = 2;
28+
const OUTDATED_SNIPPET_NOT_FOUND_ERROR =
29+
"old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing.";
2630

2731
type LineIndex = {
2832
lines: string[];
@@ -242,6 +246,17 @@ export async function handleEditTool(
242246
}
243247

244248
if (matches.length === 0) {
249+
if (snippet && hasSnippetOutdatedFileVersion(context.sessionId, snippet)) {
250+
return {
251+
ok: false,
252+
name: "edit",
253+
error: OUTDATED_SNIPPET_NOT_FOUND_ERROR,
254+
metadata: {
255+
scope: formatScopeMetadata(scope),
256+
},
257+
};
258+
}
259+
245260
const closestMatch = findClosestMatch(raw, oldString, scope, lineIndex);
246261
return {
247262
ok: false,
@@ -295,13 +310,17 @@ export async function handleEditTool(
295310
const diffPreview = buildDiffPreview(filePath, raw, updated);
296311
writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings);
297312
const freshMetadata = readTextFileWithMetadata(filePath);
298-
recordFileState(context.sessionId, {
299-
filePath,
300-
content: freshMetadata.content,
301-
timestamp: freshMetadata.timestamp,
302-
encoding: freshMetadata.encoding,
303-
lineEndings: freshMetadata.lineEndings,
304-
});
313+
recordFileState(
314+
context.sessionId,
315+
{
316+
filePath,
317+
content: freshMetadata.content,
318+
timestamp: freshMetadata.timestamp,
319+
encoding: freshMetadata.encoding,
320+
lineEndings: freshMetadata.lineEndings,
321+
},
322+
{ incrementVersion: true }
323+
);
305324
const replacedCount = replaceAll ? matches.length : 1;
306325
return {
307326
ok: true,
@@ -603,8 +622,8 @@ function findClosestMatch(
603622
}
604623
}
605624

606-
if (bestLooseMatch) {
607-
return bestLooseMatch;
625+
if (bestLooseMatch && bestLooseMatch.score >= MIN_FUZZY_SCORE) {
626+
return expandClosestMatch(raw, lineIndex, scope, bestLooseMatch);
608627
}
609628
}
610629

@@ -640,7 +659,23 @@ function findClosestMatch(
640659
}
641660
}
642661

643-
return bestMatch;
662+
return bestMatch ? expandClosestMatch(raw, lineIndex, scope, bestMatch) : null;
663+
}
664+
665+
function expandClosestMatch(
666+
raw: string,
667+
lineIndex: LineIndex,
668+
scope: SearchScope,
669+
closestMatch: ClosestMatch
670+
): ClosestMatch {
671+
const startLine = clamp(closestMatch.startLine - CLOSEST_MATCH_CONTEXT_LINES, scope.startLine, scope.endLine);
672+
const endLine = clamp(closestMatch.endLine + CLOSEST_MATCH_CONTEXT_LINES, startLine, scope.endLine);
673+
return {
674+
...closestMatch,
675+
text: sliceLines(raw, lineIndex, startLine, endLine),
676+
startLine,
677+
endLine,
678+
};
644679
}
645680

646681
function buildLooseEscapeRegex(source: string): RegExp | null {

src/tools/write-handler.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,17 @@ export async function handleWriteTool(
100100
const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings);
101101
const freshMetadata = readTextFileWithMetadata(filePath);
102102

103-
recordFileState(context.sessionId, {
104-
filePath,
105-
content: freshMetadata.content,
106-
timestamp: freshMetadata.timestamp,
107-
encoding: freshMetadata.encoding,
108-
lineEndings: freshMetadata.lineEndings,
109-
});
103+
recordFileState(
104+
context.sessionId,
105+
{
106+
filePath,
107+
content: freshMetadata.content,
108+
timestamp: freshMetadata.timestamp,
109+
encoding: freshMetadata.encoding,
110+
lineEndings: freshMetadata.lineEndings,
111+
},
112+
{ incrementVersion: true }
113+
);
110114

111115
return {
112116
ok: true,

0 commit comments

Comments
 (0)