Skip to content

Commit ef4b950

Browse files
authored
fix: stabilize file paths during native tool call streaming (#10555)
1 parent e3c0cd6 commit ef4b950

File tree

9 files changed

+109
-43
lines changed

9 files changed

+109
-43
lines changed

src/core/tools/ApplyDiffTool.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
259259
}
260260

261261
await task.diffViewProvider.reset()
262+
this.resetPartialState()
262263

263264
// Process any queued messages after file edit completes
264265
task.processQueuedMessages()
@@ -267,6 +268,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
267268
} catch (error) {
268269
await handleError("applying diff", error as Error)
269270
await task.diffViewProvider.reset()
271+
this.resetPartialState()
270272
task.processQueuedMessages()
271273
return
272274
}
@@ -276,9 +278,14 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
276278
const relPath: string | undefined = block.params.path
277279
const diffContent: string | undefined = block.params.diff
278280

281+
// Wait for path to stabilize before showing UI (prevents truncated paths)
282+
if (!this.hasPathStabilized(relPath)) {
283+
return
284+
}
285+
279286
const sharedMessageProps: ClineSayTool = {
280287
tool: "appliedDiff",
281-
path: getReadablePath(task.cwd, relPath || ""),
288+
path: getReadablePath(task.cwd, relPath),
282289
diff: diffContent,
283290
}
284291

src/core/tools/BaseTool.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export abstract class BaseTool<TName extends ToolName> {
4747
*/
4848
abstract readonly name: TName
4949

50+
/**
51+
* Track the last seen path during streaming to detect when the path has stabilized.
52+
* Used by hasPathStabilized() to prevent displaying truncated paths from partial-json parsing.
53+
*/
54+
protected lastSeenPartialPath: string | undefined = undefined
55+
5056
/**
5157
* Parse XML/legacy string-based parameters into typed parameters.
5258
*
@@ -120,6 +126,41 @@ export abstract class BaseTool<TName extends ToolName> {
120126
return text.replace(tagRegex, "")
121127
}
122128

129+
/**
130+
* Check if a path parameter has stabilized during streaming.
131+
*
132+
* During native tool call streaming, the partial-json library may return truncated
133+
* string values when chunk boundaries fall mid-value. This method tracks the path
134+
* value between consecutive handlePartial() calls and returns true only when the
135+
* path has stopped changing (stabilized).
136+
*
137+
* Usage in handlePartial():
138+
* ```typescript
139+
* if (!this.hasPathStabilized(block.params.path)) {
140+
* return // Path still changing, wait for it to stabilize
141+
* }
142+
* // Path is stable, proceed with UI updates
143+
* ```
144+
*
145+
* @param path - The current path value from the partial block
146+
* @returns true if path has stabilized (same value seen twice) and is non-empty, false otherwise
147+
*/
148+
protected hasPathStabilized(path: string | undefined): boolean {
149+
const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === path
150+
this.lastSeenPartialPath = path
151+
return pathHasStabilized && !!path
152+
}
153+
154+
/**
155+
* Reset the partial state tracking.
156+
*
157+
* Should be called at the end of execute() (both success and error paths)
158+
* to ensure clean state for the next tool invocation.
159+
*/
160+
resetPartialState(): void {
161+
this.lastSeenPartialPath = undefined
162+
}
163+
123164
/**
124165
* Main entry point for tool execution.
125166
*

src/core/tools/EditFileTool.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -327,19 +327,26 @@ export class EditFileTool extends BaseTool<"edit_file"> {
327327
// Record successful tool usage and cleanup
328328
task.recordToolUsage("edit_file")
329329
await task.diffViewProvider.reset()
330+
this.resetPartialState()
330331

331332
// Process any queued messages after file edit completes
332333
task.processQueuedMessages()
333334
} catch (error) {
334335
await handleError("edit_file", error as Error)
335336
await task.diffViewProvider.reset()
337+
this.resetPartialState()
336338
}
337339
}
338340

339341
override async handlePartial(task: Task, block: ToolUse<"edit_file">): Promise<void> {
340342
const filePath: string | undefined = block.params.file_path
341343
const oldString: string | undefined = block.params.old_string
342344

345+
// Wait for path to stabilize before showing UI (prevents truncated paths)
346+
if (!this.hasPathStabilized(filePath)) {
347+
return
348+
}
349+
343350
let operationPreview: string | undefined
344351
if (oldString !== undefined) {
345352
if (oldString === "") {
@@ -350,14 +357,14 @@ export class EditFileTool extends BaseTool<"edit_file"> {
350357
}
351358
}
352359

353-
// Determine relative path for display
354-
let relPath = filePath || ""
355-
if (filePath && path.isAbsolute(filePath)) {
356-
relPath = path.relative(task.cwd, filePath)
360+
// Determine relative path for display (filePath is guaranteed non-null after hasPathStabilized)
361+
let relPath = filePath!
362+
if (path.isAbsolute(relPath)) {
363+
relPath = path.relative(task.cwd, relPath)
357364
}
358365

359-
const absolutePath = relPath ? path.resolve(task.cwd, relPath) : ""
360-
const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false
366+
const absolutePath = path.resolve(task.cwd, relPath)
367+
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
361368

362369
const sharedMessageProps: ClineSayTool = {
363370
tool: "appliedDiff",

src/core/tools/SearchAndReplaceTool.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,17 +259,25 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> {
259259
// Record successful tool usage and cleanup
260260
task.recordToolUsage("search_and_replace")
261261
await task.diffViewProvider.reset()
262+
this.resetPartialState()
262263

263264
// Process any queued messages after file edit completes
264265
task.processQueuedMessages()
265266
} catch (error) {
266267
await handleError("search and replace", error as Error)
267268
await task.diffViewProvider.reset()
269+
this.resetPartialState()
268270
}
269271
}
270272

271273
override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise<void> {
272274
const relPath: string | undefined = block.params.path
275+
276+
// Wait for path to stabilize before showing UI (prevents truncated paths)
277+
if (!this.hasPathStabilized(relPath)) {
278+
return
279+
}
280+
273281
const operationsStr: string | undefined = block.params.operations
274282

275283
let operationsPreview: string | undefined
@@ -284,12 +292,13 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> {
284292
}
285293
}
286294

287-
const absolutePath = relPath ? path.resolve(task.cwd, relPath) : ""
288-
const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false
295+
// relPath is guaranteed non-null after hasPathStabilized
296+
const absolutePath = path.resolve(task.cwd, relPath!)
297+
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
289298

290299
const sharedMessageProps: ClineSayTool = {
291300
tool: "appliedDiff",
292-
path: getReadablePath(task.cwd, relPath || ""),
301+
path: getReadablePath(task.cwd, relPath!),
293302
diff: operationsPreview,
294303
isOutsideWorkspace,
295304
}

src/core/tools/SearchReplaceTool.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,34 +240,41 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> {
240240
// Record successful tool usage and cleanup
241241
task.recordToolUsage("search_replace")
242242
await task.diffViewProvider.reset()
243+
this.resetPartialState()
243244

244245
// Process any queued messages after file edit completes
245246
task.processQueuedMessages()
246247
} catch (error) {
247248
await handleError("search and replace", error as Error)
248249
await task.diffViewProvider.reset()
250+
this.resetPartialState()
249251
}
250252
}
251253

252254
override async handlePartial(task: Task, block: ToolUse<"search_replace">): Promise<void> {
253255
const filePath: string | undefined = block.params.file_path
254256
const oldString: string | undefined = block.params.old_string
255257

258+
// Wait for path to stabilize before showing UI (prevents truncated paths)
259+
if (!this.hasPathStabilized(filePath)) {
260+
return
261+
}
262+
256263
let operationPreview: string | undefined
257264
if (oldString) {
258265
// Show a preview of what will be replaced
259266
const preview = oldString.length > 50 ? oldString.substring(0, 50) + "..." : oldString
260267
operationPreview = `replacing: "${preview}"`
261268
}
262269

263-
// Determine relative path for display
264-
let relPath = filePath || ""
265-
if (filePath && path.isAbsolute(filePath)) {
266-
relPath = path.relative(task.cwd, filePath)
270+
// Determine relative path for display (filePath is guaranteed non-null after hasPathStabilized)
271+
let relPath = filePath!
272+
if (path.isAbsolute(relPath)) {
273+
relPath = path.relative(task.cwd, relPath)
267274
}
268275

269-
const absolutePath = relPath ? path.resolve(task.cwd, relPath) : ""
270-
const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false
276+
const absolutePath = path.resolve(task.cwd, relPath)
277+
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
271278

272279
const sharedMessageProps: ClineSayTool = {
273280
tool: "appliedDiff",

src/core/tools/WriteToFileTool.ts

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -200,21 +200,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
200200
}
201201
}
202202

203-
// Track the last seen path during streaming to detect when the path has stabilized
204-
private lastSeenPartialPath: string | undefined = undefined
205-
206203
override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise<void> {
207204
const relPath: string | undefined = block.params.path
208205
let newContent: string | undefined = block.params.content
209206

210-
// During streaming, the partial-json library may return truncated string values
211-
// when chunk boundaries fall mid-value. To avoid creating files at incorrect paths,
212-
// we wait until the path stops changing between consecutive partial blocks before
213-
// creating the file. This ensures we have the complete, final path value.
214-
const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath
215-
this.lastSeenPartialPath = relPath
216-
217-
if (!pathHasStabilized || !relPath || newContent === undefined) {
207+
// Wait for path to stabilize before showing UI (prevents truncated paths)
208+
if (!this.hasPathStabilized(relPath) || newContent === undefined) {
218209
return
219210
}
220211

@@ -229,8 +220,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
229220
return
230221
}
231222

223+
// relPath is guaranteed non-null after hasPathStabilized
232224
let fileExists: boolean
233-
const absolutePath = path.resolve(task.cwd, relPath)
225+
const absolutePath = path.resolve(task.cwd, relPath!)
234226

235227
if (task.diffViewProvider.editType !== undefined) {
236228
fileExists = task.diffViewProvider.editType === "modify"
@@ -245,13 +237,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
245237
await createDirectoriesForFile(absolutePath)
246238
}
247239

248-
const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false
249-
const fullPath = absolutePath
250-
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
240+
const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false
241+
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
251242

252243
const sharedMessageProps: ClineSayTool = {
253244
tool: fileExists ? "editedExistingFile" : "newFileCreated",
254-
path: getReadablePath(task.cwd, relPath),
245+
path: getReadablePath(task.cwd, relPath!),
255246
content: newContent || "",
256247
isOutsideWorkspace,
257248
isProtected: isWriteProtected,
@@ -262,7 +253,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
262253

263254
if (newContent) {
264255
if (!task.diffViewProvider.isEditing) {
265-
await task.diffViewProvider.open(relPath)
256+
await task.diffViewProvider.open(relPath!)
266257
}
267258

268259
await task.diffViewProvider.update(
@@ -271,13 +262,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
271262
)
272263
}
273264
}
274-
275-
/**
276-
* Reset state when the tool finishes (called from execute or on error)
277-
*/
278-
resetPartialState(): void {
279-
this.lastSeenPartialPath = undefined
280-
}
281265
}
282266

283267
export const writeToFileTool = new WriteToFileTool()

src/core/tools/__tests__/editFileTool.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,18 @@ describe("editFileTool", () => {
363363
})
364364

365365
describe("partial block handling", () => {
366-
it("handles partial block without errors", async () => {
366+
it("handles partial block without errors after path stabilizes", async () => {
367+
// Path stabilization requires two consecutive calls with the same path
368+
// First call sets lastSeenPartialPath, second call sees it has stabilized
369+
await executeEditFileTool({}, { isPartial: true })
367370
await executeEditFileTool({}, { isPartial: true })
368371

369372
expect(mockTask.ask).toHaveBeenCalled()
370373
})
371374

372375
it("shows creating new file preview when old_string is empty", async () => {
376+
// Path stabilization requires two consecutive calls with the same path
377+
await executeEditFileTool({ old_string: "" }, { isPartial: true })
373378
await executeEditFileTool({ old_string: "" }, { isPartial: true })
374379

375380
expect(mockTask.ask).toHaveBeenCalled()

src/core/tools/__tests__/searchAndReplaceTool.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,10 @@ describe("searchAndReplaceTool", () => {
346346
})
347347

348348
describe("partial block handling", () => {
349-
it("handles partial block without errors", async () => {
349+
it("handles partial block without errors after path stabilizes", async () => {
350+
// Path stabilization requires two consecutive calls with the same path
351+
// First call sets lastSeenPartialPath, second call sees it has stabilized
352+
await executeSearchAndReplaceTool({}, { isPartial: true })
350353
await executeSearchAndReplaceTool({}, { isPartial: true })
351354

352355
expect(mockTask.ask).toHaveBeenCalled()

src/core/tools/__tests__/searchReplaceTool.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,10 @@ describe("searchReplaceTool", () => {
321321
})
322322

323323
describe("partial block handling", () => {
324-
it("handles partial block without errors", async () => {
324+
it("handles partial block without errors after path stabilizes", async () => {
325+
// Path stabilization requires two consecutive calls with the same path
326+
// First call sets lastSeenPartialPath, second call sees it has stabilized
327+
await executeSearchReplaceTool({}, { isPartial: true })
325328
await executeSearchReplaceTool({}, { isPartial: true })
326329

327330
expect(mockCline.ask).toHaveBeenCalled()

0 commit comments

Comments
 (0)