From 84ab6719ffb67c79f1eaeba19b4dc442e16ff918 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 8 Jan 2026 17:15:46 -0500 Subject: [PATCH 1/2] fix: stabilize file paths during native tool call streaming During native tool call streaming, the partial-json library may return truncated string values when chunk boundaries fall mid-value. This was causing truncated file paths to display in the UI (e.g., 'src/bug-tracker' showing as 'extracker'). This fix implements path stabilization pattern across file-editing tools: - Track the last seen path value between consecutive handlePartial() calls - Only proceed with UI display when the path value has stopped changing - Reset tracking state at the start of each execute() call Tools updated: - ApplyDiffTool.ts - EditFileTool.ts - SearchReplaceTool.ts - SearchAndReplaceTool.ts Tests updated: - editFileTool.spec.ts - searchReplaceTool.spec.ts - searchAndReplaceTool.spec.ts Closes ROO-423 --- src/core/tools/ApplyDiffTool.ts | 25 ++++++++++++++- src/core/tools/EditFileTool.ts | 31 ++++++++++++++++--- src/core/tools/SearchAndReplaceTool.ts | 27 ++++++++++++++++ src/core/tools/SearchReplaceTool.ts | 31 ++++++++++++++++--- src/core/tools/__tests__/editFileTool.spec.ts | 7 ++++- .../__tests__/searchAndReplaceTool.spec.ts | 5 ++- .../tools/__tests__/searchReplaceTool.spec.ts | 5 ++- 7 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 7161c7c08ef..7a0628cbfd3 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -24,6 +24,9 @@ interface ApplyDiffParams { export class ApplyDiffTool extends BaseTool<"apply_diff"> { readonly name = "apply_diff" as const + // Track the last seen path during streaming to detect when the path has stabilized + private lastSeenPartialPath: string | undefined = undefined + parseLegacy(params: Partial>): ApplyDiffParams { return { path: params.path || "", @@ -259,6 +262,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } await task.diffViewProvider.reset() + this.resetPartialState() // Process any queued messages after file edit completes task.processQueuedMessages() @@ -267,6 +271,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } catch (error) { await handleError("applying diff", error as Error) await task.diffViewProvider.reset() + this.resetPartialState() task.processQueuedMessages() return } @@ -276,9 +281,20 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const relPath: string | undefined = block.params.path const diffContent: string | undefined = block.params.diff + // During streaming, the partial-json library may return truncated string values + // when chunk boundaries fall mid-value. To avoid showing incorrect file paths, + // we wait until the path stops changing between consecutive partial blocks before + // displaying the tool UI. This ensures we have the complete, final path value. + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath + this.lastSeenPartialPath = relPath + + if (!pathHasStabilized || !relPath) { + return + } + const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", - path: getReadablePath(task.cwd, relPath || ""), + path: getReadablePath(task.cwd, relPath), diff: diffContent, } @@ -294,6 +310,13 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus).catch(() => {}) } + + /** + * Reset state when the tool finishes (called from execute or on error) + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } } export const applyDiffTool = new ApplyDiffTool() diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 8d04fe23016..c5350a24818 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -93,6 +93,9 @@ function applyReplacement( export class EditFileTool extends BaseTool<"edit_file"> { readonly name = "edit_file" as const + // Track the last seen path during streaming to detect when the path has stabilized + private lastSeenPartialPath: string | undefined = undefined + parseLegacy(params: Partial>): EditFileParams { return { file_path: params.file_path || "", @@ -327,12 +330,14 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Record successful tool usage and cleanup task.recordToolUsage("edit_file") await task.diffViewProvider.reset() + this.resetPartialState() // Process any queued messages after file edit completes task.processQueuedMessages() } catch (error) { await handleError("edit_file", error as Error) await task.diffViewProvider.reset() + this.resetPartialState() } } @@ -340,6 +345,17 @@ export class EditFileTool extends BaseTool<"edit_file"> { const filePath: string | undefined = block.params.file_path const oldString: string | undefined = block.params.old_string + // During streaming, the partial-json library may return truncated string values + // when chunk boundaries fall mid-value. To avoid showing incorrect file paths, + // we wait until the path stops changing between consecutive partial blocks before + // displaying the tool UI. This ensures we have the complete, final path value. + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === filePath + this.lastSeenPartialPath = filePath + + if (!pathHasStabilized || !filePath) { + return + } + let operationPreview: string | undefined if (oldString !== undefined) { if (oldString === "") { @@ -351,13 +367,13 @@ export class EditFileTool extends BaseTool<"edit_file"> { } // Determine relative path for display - let relPath = filePath || "" - if (filePath && path.isAbsolute(filePath)) { + let relPath = filePath + if (path.isAbsolute(filePath)) { relPath = path.relative(task.cwd, filePath) } - const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" - const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false + const absolutePath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", @@ -368,6 +384,13 @@ export class EditFileTool extends BaseTool<"edit_file"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } + + /** + * Reset state when the tool finishes (called from execute or on error) + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } } export const editFileTool = new EditFileTool() diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 7d03a6a22c3..4d0bf5b72f0 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -27,6 +27,9 @@ interface SearchAndReplaceParams { export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { readonly name = "search_and_replace" as const + // Track the last seen path during streaming to detect when it stabilizes + private lastSeenPartialPath: string | undefined = undefined + parseLegacy(params: Partial>): SearchAndReplaceParams { // Parse operations from JSON string if provided let operations: SearchReplaceOperation[] = [] @@ -45,6 +48,9 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } async execute(params: SearchAndReplaceParams, task: Task, callbacks: ToolCallbacks): Promise { + // Reset partial state at start of execution + this.resetPartialState() + const { path: relPath, operations } = params const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks @@ -259,17 +265,30 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { // Record successful tool usage and cleanup task.recordToolUsage("search_and_replace") await task.diffViewProvider.reset() + this.resetPartialState() // Process any queued messages after file edit completes task.processQueuedMessages() } catch (error) { await handleError("search and replace", error as Error) await task.diffViewProvider.reset() + this.resetPartialState() } } override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise { const relPath: string | undefined = block.params.path + + // Wait until path stops changing between consecutive partial blocks. + // This prevents displaying truncated paths when parameters arrive + // in different orders during native tool call streaming. + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath + this.lastSeenPartialPath = relPath + + if (!pathHasStabilized || !relPath) { + return + } + const operationsStr: string | undefined = block.params.operations let operationsPreview: string | undefined @@ -296,6 +315,14 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } + + /** + * Reset streaming state. Called at start of execute() to ensure + * fresh state for each tool invocation. + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } } /** diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index dadb97fde5a..cc4f45d0870 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -23,6 +23,9 @@ interface SearchReplaceParams { export class SearchReplaceTool extends BaseTool<"search_replace"> { readonly name = "search_replace" as const + // Track the last seen path during streaming to detect when the path has stabilized + private lastSeenPartialPath: string | undefined = undefined + parseLegacy(params: Partial>): SearchReplaceParams { return { file_path: params.file_path || "", @@ -240,12 +243,14 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Record successful tool usage and cleanup task.recordToolUsage("search_replace") await task.diffViewProvider.reset() + this.resetPartialState() // Process any queued messages after file edit completes task.processQueuedMessages() } catch (error) { await handleError("search and replace", error as Error) await task.diffViewProvider.reset() + this.resetPartialState() } } @@ -253,6 +258,17 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const filePath: string | undefined = block.params.file_path const oldString: string | undefined = block.params.old_string + // During streaming, the partial-json library may return truncated string values + // when chunk boundaries fall mid-value. To avoid showing incorrect file paths, + // we wait until the path stops changing between consecutive partial blocks before + // displaying the tool UI. This ensures we have the complete, final path value. + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === filePath + this.lastSeenPartialPath = filePath + + if (!pathHasStabilized || !filePath) { + return + } + let operationPreview: string | undefined if (oldString) { // Show a preview of what will be replaced @@ -261,13 +277,13 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { } // Determine relative path for display - let relPath = filePath || "" - if (filePath && path.isAbsolute(filePath)) { + let relPath = filePath + if (path.isAbsolute(filePath)) { relPath = path.relative(task.cwd, filePath) } - const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" - const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false + const absolutePath = path.resolve(task.cwd, relPath) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", @@ -278,6 +294,13 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } + + /** + * Reset state when the tool finishes (called from execute or on error) + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } } export const searchReplaceTool = new SearchReplaceTool() diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index ab632252dff..24ead6446b5 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -363,13 +363,18 @@ describe("editFileTool", () => { }) describe("partial block handling", () => { - it("handles partial block without errors", async () => { + it("handles partial block without errors after path stabilizes", async () => { + // Path stabilization requires two consecutive calls with the same path + // First call sets lastSeenPartialPath, second call sees it has stabilized + await executeEditFileTool({}, { isPartial: true }) await executeEditFileTool({}, { isPartial: true }) expect(mockTask.ask).toHaveBeenCalled() }) it("shows creating new file preview when old_string is empty", async () => { + // Path stabilization requires two consecutive calls with the same path + await executeEditFileTool({ old_string: "" }, { isPartial: true }) await executeEditFileTool({ old_string: "" }, { isPartial: true }) expect(mockTask.ask).toHaveBeenCalled() diff --git a/src/core/tools/__tests__/searchAndReplaceTool.spec.ts b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts index c73744ec57c..4566ca202e5 100644 --- a/src/core/tools/__tests__/searchAndReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts @@ -346,7 +346,10 @@ describe("searchAndReplaceTool", () => { }) describe("partial block handling", () => { - it("handles partial block without errors", async () => { + it("handles partial block without errors after path stabilizes", async () => { + // Path stabilization requires two consecutive calls with the same path + // First call sets lastSeenPartialPath, second call sees it has stabilized + await executeSearchAndReplaceTool({}, { isPartial: true }) await executeSearchAndReplaceTool({}, { isPartial: true }) expect(mockTask.ask).toHaveBeenCalled() diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 984808e9715..4f69e8e8591 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -321,7 +321,10 @@ describe("searchReplaceTool", () => { }) describe("partial block handling", () => { - it("handles partial block without errors", async () => { + it("handles partial block without errors after path stabilizes", async () => { + // Path stabilization requires two consecutive calls with the same path + // First call sets lastSeenPartialPath, second call sees it has stabilized + await executeSearchReplaceTool({}, { isPartial: true }) await executeSearchReplaceTool({}, { isPartial: true }) expect(mockCline.ask).toHaveBeenCalled() From 0f835ea559a77917b0f1019c84181ce070debd5e Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 8 Jan 2026 17:31:30 -0500 Subject: [PATCH 2/2] refactor: extract path stabilization into BaseTool Move the repeated path stabilization pattern into the BaseTool base class: - Add lastSeenPartialPath protected property - Add hasPathStabilized(path) helper method - Keep resetPartialState() in base class This DRYs up the code across all file-editing tools: - ApplyDiffTool - EditFileTool - SearchReplaceTool - SearchAndReplaceTool - WriteToFileTool --- src/core/tools/ApplyDiffTool.ts | 20 ++----------- src/core/tools/BaseTool.ts | 41 ++++++++++++++++++++++++++ src/core/tools/EditFileTool.ts | 28 ++++-------------- src/core/tools/SearchAndReplaceTool.ts | 30 ++++--------------- src/core/tools/SearchReplaceTool.ts | 28 ++++-------------- src/core/tools/WriteToFileTool.ts | 32 +++++--------------- 6 files changed, 69 insertions(+), 110 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 7a0628cbfd3..7d026b2f3c8 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -24,9 +24,6 @@ interface ApplyDiffParams { export class ApplyDiffTool extends BaseTool<"apply_diff"> { readonly name = "apply_diff" as const - // Track the last seen path during streaming to detect when the path has stabilized - private lastSeenPartialPath: string | undefined = undefined - parseLegacy(params: Partial>): ApplyDiffParams { return { path: params.path || "", @@ -281,14 +278,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const relPath: string | undefined = block.params.path const diffContent: string | undefined = block.params.diff - // During streaming, the partial-json library may return truncated string values - // when chunk boundaries fall mid-value. To avoid showing incorrect file paths, - // we wait until the path stops changing between consecutive partial blocks before - // displaying the tool UI. This ensures we have the complete, final path value. - const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath - this.lastSeenPartialPath = relPath - - if (!pathHasStabilized || !relPath) { + // Wait for path to stabilize before showing UI (prevents truncated paths) + if (!this.hasPathStabilized(relPath)) { return } @@ -310,13 +301,6 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial, toolProgressStatus).catch(() => {}) } - - /** - * Reset state when the tool finishes (called from execute or on error) - */ - resetPartialState(): void { - this.lastSeenPartialPath = undefined - } } export const applyDiffTool = new ApplyDiffTool() diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 5d4ec633d1f..764cde59c59 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -47,6 +47,12 @@ export abstract class BaseTool { */ abstract readonly name: TName + /** + * Track the last seen path during streaming to detect when the path has stabilized. + * Used by hasPathStabilized() to prevent displaying truncated paths from partial-json parsing. + */ + protected lastSeenPartialPath: string | undefined = undefined + /** * Parse XML/legacy string-based parameters into typed parameters. * @@ -120,6 +126,41 @@ export abstract class BaseTool { return text.replace(tagRegex, "") } + /** + * Check if a path parameter has stabilized during streaming. + * + * During native tool call streaming, the partial-json library may return truncated + * string values when chunk boundaries fall mid-value. This method tracks the path + * value between consecutive handlePartial() calls and returns true only when the + * path has stopped changing (stabilized). + * + * Usage in handlePartial(): + * ```typescript + * if (!this.hasPathStabilized(block.params.path)) { + * return // Path still changing, wait for it to stabilize + * } + * // Path is stable, proceed with UI updates + * ``` + * + * @param path - The current path value from the partial block + * @returns true if path has stabilized (same value seen twice) and is non-empty, false otherwise + */ + protected hasPathStabilized(path: string | undefined): boolean { + const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === path + this.lastSeenPartialPath = path + return pathHasStabilized && !!path + } + + /** + * Reset the partial state tracking. + * + * Should be called at the end of execute() (both success and error paths) + * to ensure clean state for the next tool invocation. + */ + resetPartialState(): void { + this.lastSeenPartialPath = undefined + } + /** * Main entry point for tool execution. * diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index c5350a24818..f710f6f5636 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -93,9 +93,6 @@ function applyReplacement( export class EditFileTool extends BaseTool<"edit_file"> { readonly name = "edit_file" as const - // Track the last seen path during streaming to detect when the path has stabilized - private lastSeenPartialPath: string | undefined = undefined - parseLegacy(params: Partial>): EditFileParams { return { file_path: params.file_path || "", @@ -345,14 +342,8 @@ export class EditFileTool extends BaseTool<"edit_file"> { const filePath: string | undefined = block.params.file_path const oldString: string | undefined = block.params.old_string - // During streaming, the partial-json library may return truncated string values - // when chunk boundaries fall mid-value. To avoid showing incorrect file paths, - // we wait until the path stops changing between consecutive partial blocks before - // displaying the tool UI. This ensures we have the complete, final path value. - const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === filePath - this.lastSeenPartialPath = filePath - - if (!pathHasStabilized || !filePath) { + // Wait for path to stabilize before showing UI (prevents truncated paths) + if (!this.hasPathStabilized(filePath)) { return } @@ -366,10 +357,10 @@ export class EditFileTool extends BaseTool<"edit_file"> { } } - // Determine relative path for display - let relPath = filePath - if (path.isAbsolute(filePath)) { - relPath = path.relative(task.cwd, filePath) + // Determine relative path for display (filePath is guaranteed non-null after hasPathStabilized) + let relPath = filePath! + if (path.isAbsolute(relPath)) { + relPath = path.relative(task.cwd, relPath) } const absolutePath = path.resolve(task.cwd, relPath) @@ -384,13 +375,6 @@ export class EditFileTool extends BaseTool<"edit_file"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } - - /** - * Reset state when the tool finishes (called from execute or on error) - */ - resetPartialState(): void { - this.lastSeenPartialPath = undefined - } } export const editFileTool = new EditFileTool() diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 4d0bf5b72f0..9c1e6e553fe 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -27,9 +27,6 @@ interface SearchAndReplaceParams { export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { readonly name = "search_and_replace" as const - // Track the last seen path during streaming to detect when it stabilizes - private lastSeenPartialPath: string | undefined = undefined - parseLegacy(params: Partial>): SearchAndReplaceParams { // Parse operations from JSON string if provided let operations: SearchReplaceOperation[] = [] @@ -48,9 +45,6 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } async execute(params: SearchAndReplaceParams, task: Task, callbacks: ToolCallbacks): Promise { - // Reset partial state at start of execution - this.resetPartialState() - const { path: relPath, operations } = params const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks @@ -279,13 +273,8 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise { const relPath: string | undefined = block.params.path - // Wait until path stops changing between consecutive partial blocks. - // This prevents displaying truncated paths when parameters arrive - // in different orders during native tool call streaming. - const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath - this.lastSeenPartialPath = relPath - - if (!pathHasStabilized || !relPath) { + // Wait for path to stabilize before showing UI (prevents truncated paths) + if (!this.hasPathStabilized(relPath)) { return } @@ -303,26 +292,19 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } } - const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" - const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false + // relPath is guaranteed non-null after hasPathStabilized + const absolutePath = path.resolve(task.cwd, relPath!) + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", - path: getReadablePath(task.cwd, relPath || ""), + path: getReadablePath(task.cwd, relPath!), diff: operationsPreview, isOutsideWorkspace, } await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } - - /** - * Reset streaming state. Called at start of execute() to ensure - * fresh state for each tool invocation. - */ - resetPartialState(): void { - this.lastSeenPartialPath = undefined - } } /** diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index cc4f45d0870..26774c96c21 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -23,9 +23,6 @@ interface SearchReplaceParams { export class SearchReplaceTool extends BaseTool<"search_replace"> { readonly name = "search_replace" as const - // Track the last seen path during streaming to detect when the path has stabilized - private lastSeenPartialPath: string | undefined = undefined - parseLegacy(params: Partial>): SearchReplaceParams { return { file_path: params.file_path || "", @@ -258,14 +255,8 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const filePath: string | undefined = block.params.file_path const oldString: string | undefined = block.params.old_string - // During streaming, the partial-json library may return truncated string values - // when chunk boundaries fall mid-value. To avoid showing incorrect file paths, - // we wait until the path stops changing between consecutive partial blocks before - // displaying the tool UI. This ensures we have the complete, final path value. - const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === filePath - this.lastSeenPartialPath = filePath - - if (!pathHasStabilized || !filePath) { + // Wait for path to stabilize before showing UI (prevents truncated paths) + if (!this.hasPathStabilized(filePath)) { return } @@ -276,10 +267,10 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { operationPreview = `replacing: "${preview}"` } - // Determine relative path for display - let relPath = filePath - if (path.isAbsolute(filePath)) { - relPath = path.relative(task.cwd, filePath) + // Determine relative path for display (filePath is guaranteed non-null after hasPathStabilized) + let relPath = filePath! + if (path.isAbsolute(relPath)) { + relPath = path.relative(task.cwd, relPath) } const absolutePath = path.resolve(task.cwd, relPath) @@ -294,13 +285,6 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) } - - /** - * Reset state when the tool finishes (called from execute or on error) - */ - resetPartialState(): void { - this.lastSeenPartialPath = undefined - } } export const searchReplaceTool = new SearchReplaceTool() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index d9c20115ea2..29e808b490e 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -200,21 +200,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } } - // Track the last seen path during streaming to detect when the path has stabilized - private lastSeenPartialPath: string | undefined = undefined - override async handlePartial(task: Task, block: ToolUse<"write_to_file">): Promise { const relPath: string | undefined = block.params.path let newContent: string | undefined = block.params.content - // During streaming, the partial-json library may return truncated string values - // when chunk boundaries fall mid-value. To avoid creating files at incorrect paths, - // we wait until the path stops changing between consecutive partial blocks before - // creating the file. This ensures we have the complete, final path value. - const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === relPath - this.lastSeenPartialPath = relPath - - if (!pathHasStabilized || !relPath || newContent === undefined) { + // Wait for path to stabilize before showing UI (prevents truncated paths) + if (!this.hasPathStabilized(relPath) || newContent === undefined) { return } @@ -229,8 +220,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + // relPath is guaranteed non-null after hasPathStabilized let fileExists: boolean - const absolutePath = path.resolve(task.cwd, relPath) + const absolutePath = path.resolve(task.cwd, relPath!) if (task.diffViewProvider.editType !== undefined) { fileExists = task.diffViewProvider.editType === "modify" @@ -245,13 +237,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await createDirectoriesForFile(absolutePath) } - const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false - const fullPath = absolutePath - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(task.cwd, relPath), + path: getReadablePath(task.cwd, relPath!), content: newContent || "", isOutsideWorkspace, isProtected: isWriteProtected, @@ -262,7 +253,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (newContent) { if (!task.diffViewProvider.isEditing) { - await task.diffViewProvider.open(relPath) + await task.diffViewProvider.open(relPath!) } await task.diffViewProvider.update( @@ -271,13 +262,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ) } } - - /** - * Reset state when the tool finishes (called from execute or on error) - */ - resetPartialState(): void { - this.lastSeenPartialPath = undefined - } } export const writeToFileTool = new WriteToFileTool()