From 8741c782e050f68dad239f8ae7fadfcd6b7a8405 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 15:58:13 +0300 Subject: [PATCH] fix: keep streamed read path empty until available (#1948) --- src/core/tools/ReadFileTool.ts | 2 +- src/core/tools/__tests__/readFileTool.spec.ts | 86 ++++++++++++++++--- src/eslint-suppressions.json | 2 +- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..00fc507692 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -650,7 +650,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" const sharedMessageProps: ClineSayTool = { tool: "readFile", - path: getReadablePath(task.cwd, filePath), + path: filePath ? getReadablePath(task.cwd, filePath) : "", isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, } const partialMessage = JSON.stringify({ diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..5f9033eb7c 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -14,6 +14,9 @@ import path from "path" +import type { ToolUse } from "../../../shared/tools" +import type { Task } from "../../task/Task" + import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" @@ -987,19 +990,82 @@ describe("ReadFileTool", () => { }) describe("handlePartial", () => { - it("should handle partial display for new format", async () => { + it.each([{}, { path: "" }, undefined])( + "renders streamed native arguments before their path arrives: %j", + async (nativeArgs) => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + const streamedBlock = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs, + partial: true, + } + + // Streaming JSON is incomplete even though finalized native arguments require a path. + await Reflect.apply(readFileTool.handlePartial, readFileTool, [mockTask, streamedBlock]) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), + true, + ) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + }, + ) + + it("renders incomplete legacy arguments without a file entry or outside-workspace warning", async () => { const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: { path: "src/app.ts" }, + mockTask.cwd = path.resolve(mockTask.cwd) + const block: ToolUse<"read_file"> = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs: { files: [], _legacyFormat: true }, partial: true, } - await readFileTool.handlePartial(mockTask as any, block as any) + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.handlePartial(mockTask as unknown as Task, block) - expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("readFile"), true) - }) + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), + true, + ) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + }) + + it.each(["src/app.ts", "src/файл.ts", "src/e\u0301-📚.ts"])( + "should preserve partial display for %s", + async (filePath) => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block: ToolUse<"read_file"> = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs: { path: filePath }, + partial: true, + } + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.handlePartial(mockTask as unknown as Task, block) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), + true, + ) + }, + ) it("should handle partial display for legacy format", async () => { const mockTask = createMockTask() @@ -1043,9 +1109,9 @@ describe("ReadFileTool", () => { expect(mockTask.ask).toHaveBeenCalled() }) - it("should gracefully handle ask rejection in partial", async () => { + it.each(["Cancelled", "superseded"])("should gracefully handle %s ask rejection in partial", async (reason) => { const mockTask = createMockTask() - mockTask.ask.mockRejectedValue(new Error("Cancelled")) + mockTask.ask.mockRejectedValue(new Error(reason)) const block = { nativeArgs: { path: "test.ts" }, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 908159f7ab..e4b15aa27e 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 96 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": {