From cf5abe64d6e8eebde4181d49a3afde4705441b5c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:24 +0800 Subject: [PATCH 01/29] fix(task): stage-independent saveClineMessages + finalize open partial tool ask --- ...resentAssistantMessage-custom-tool.spec.ts | 1 + src/core/task/Task.ts | 80 +++- src/core/task/__tests__/Task.spec.ts | 400 ++++++++++++++++++ 3 files changed, 476 insertions(+), 5 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index aa278e077d..3bdc4ede7b 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -89,6 +89,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { }, say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), } // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7f92f3647f..dd07714a3a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -68,7 +68,7 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error" // shared -import { findLastIndex } from "../../shared/array" +import { findLast, findLastIndex } from "../../shared/array" import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" import { t } from "../../i18n" @@ -1678,7 +1678,19 @@ export class Task extends EventEmitter implements TaskLike { } } - /** Persists Cline messages and updates task metadata in the history store. Returns false on failure. */ + /** + * Persist the message array, then refresh the derived metadata / task-history entries. + * + * The returned boolean reflects the message write only: `saveTaskMessages` failure + * leaves the on-disk record stale, so callers gating UI updates on durable state must + * skip them. Metadata / task-history stage failures are logged and swallowed — the + * message array is already persisted, and the next save recomputes and re-emits the + * metadata. + * + * `merge` (default `true`) is passed through to `saveTaskMessages`: the in-memory + * snapshot is merged with the on-disk record. `overwriteClineMessages` passes `false` + * to replace the stored messages outright. + */ private async saveClineMessages(merge = true): Promise { try { await saveTaskMessages({ @@ -1687,7 +1699,12 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, merge, }) + } catch (error) { + console.error("Failed to save Roo messages:", error) + return false + } + try { if (this._taskApiConfigName === undefined) { await this.taskApiConfigReady } @@ -1715,11 +1732,14 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) - return true } catch (error) { - console.error("Failed to save Roo messages:", error) - return false + // The message array was persisted above; a metadata or task-history failure must + // not mask that write (see the method docs). The next saveClineMessages() call + // recomputes and re-emits the metadata update. + console.error("Failed to save task metadata:", error) } + + return true } private findMessageByTimestamp(ts: number): ClineMessage | undefined { @@ -2712,6 +2732,56 @@ export class Task extends EventEmitter implements TaskLike { return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) } + /** + * Finalize a partial "tool" ask message without blocking for user input. + * Call this in error paths where a partial tool message was opened during streaming + * but execution failed before the normal approval flow could close it, so the webview + * spinner does not get stuck in a loading state. + * + * The matching partial message may no longer be the final entry if another asynchronous + * message was inserted between the partial ask and the error handler, so search backward + * instead of relying on clineMessages.at(-1). + * + * Any in-progress `progressStatus` on the message is cleared as well: the ask is being + * finalized because it will NOT complete, so a stale "in progress" indicator would be + * misleading (the normal completion path overwrites it with the final status instead). + * + * `isAnswered` is stamped true because the ask is resolved by the system rather than + * by the user: ChatView only shows ask buttons for unanswered messages, so leaving it + * unset would keep Save/Reject armed for a write that already failed. + */ + async finalizePartialToolAsk(text?: string): Promise { + const partialToolAsk = findLast( + this.clineMessages, + (message) => + message.partial === true && + message.type === "ask" && + message.ask === "tool" && + (text === undefined || message.text === text), + ) + + if (!partialToolAsk) { + return + } + + partialToolAsk.partial = false + partialToolAsk.progressStatus = undefined + partialToolAsk.isAnswered = true + const saved = await this.saveClineMessages() + if (!saved) { + // The persistence write failed: the on-disk record still carries `partial: true` + // while the in-memory message is finalized. Skip the webview-only update so the + // two views do not diverge (a later state resync or restart reload would flip the + // spinner back on from the stale disk record). The next saveClineMessages() call + // re-persists the full message array and repairs the disk record. + console.error("[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update") + return + } + await this.updateClineMessage(partialToolAsk).catch((error) => { + console.error("[Task#finalizePartialToolAsk] updateClineMessage failed:", error) + }) + } + // Lifecycle // Start / Resume / Abort / Dispose diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 88db6f2f2a..4f8b198c5f 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1,5 +1,6 @@ // npx vitest core/task/__tests__/Task.spec.ts +import * as fsReal from "fs" import * as os from "os" import * as path from "path" @@ -10,6 +11,7 @@ import type { Mock } from "vitest" import { providerIdentifiers, RooCodeEventName, + type ClineMessage, type GlobalState, type HistoryItem, type ProviderSettings, @@ -5922,6 +5924,404 @@ describe("Cline", () => { saveSpy.mockRestore() }) + it("finalizePartialToolAsk persists and updates a non-last partial tool ask", async () => { + let updateSnapshot: Record | undefined + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async (message) => { + updateSnapshot = { ...message } + }) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const partialToolAsk = { + ts: Date.now() - 2, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: true, + progressStatus: { text: "Generating…", icon: "sync" }, + } + + task.clineMessages.push(partialToolAsk) + task.clineMessages.push({ + ts: Date.now() - 1, + type: "say", + say: "error", + text: "intervening async message", + }) + + await task.finalizePartialToolAsk("partial tool message") + await flushMicrotasks() + + expect(partialToolAsk.partial).toBe(false) + expect(partialToolAsk.progressStatus).toBeUndefined() + // The ask is resolved by the system, not the user: stamp isAnswered so ChatView + // does not keep Save/Reject armed for a write that already failed. + expect(task.clineMessages[0].isAnswered).toBe(true) + expect(saveSpy).toHaveBeenCalled() + expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) + expect(updateSnapshot?.partial).toBe(false) + expect(updateSnapshot?.progressStatus).toBeUndefined() + expect(updateSnapshot?.isAnswered).toBe(true) + + updateSpy.mockRestore() + saveSpy.mockRestore() + }) + + it("finalizePartialToolAsk ignores non-matching partial tool asks when text is provided", async () => { + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockResolvedValue(undefined) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + task.clineMessages.push({ + ts: Date.now() - 1, + type: "ask", + ask: "tool", + text: "other partial tool message", + partial: true, + }) + + await task.finalizePartialToolAsk("target partial tool message") + await flushMicrotasks() + + expect(task.clineMessages[0].partial).toBe(true) + expect(task.clineMessages[0].isAnswered).toBeUndefined() + expect(saveSpy).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + + updateSpy.mockRestore() + saveSpy.mockRestore() + }) + + it("finalizePartialToolAsk updates the latest partial tool ask when no text is provided", async () => { + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockResolvedValue(undefined) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const olderPartialToolAsk = { + ts: Date.now() - 2, + type: "ask" as const, + ask: "tool" as const, + text: "older partial tool message", + partial: true, + } + const latestPartialToolAsk = { + ts: Date.now() - 1, + type: "ask" as const, + ask: "tool" as const, + text: "latest partial tool message", + partial: true, + } + + task.clineMessages.push(olderPartialToolAsk) + task.clineMessages.push(latestPartialToolAsk) + + await task.finalizePartialToolAsk() + await flushMicrotasks() + + expect(olderPartialToolAsk.partial).toBe(true) + expect(task.clineMessages[0].isAnswered).toBeUndefined() + expect(latestPartialToolAsk.partial).toBe(false) + // Only the finalized ask is stamped answered; the untouched one is not. + expect(task.clineMessages[1].isAnswered).toBe(true) + expect(saveSpy).toHaveBeenCalled() + expect(updateSpy).toHaveBeenCalledWith(latestPartialToolAsk) + + updateSpy.mockRestore() + saveSpy.mockRestore() + }) + + it("finalizePartialToolAsk logs (instead of rejecting) when updateClineMessage rejects", async () => { + // Pins the .catch arm on updateClineMessage in finalizePartialToolAsk: the + // partial flag must already be persisted (saveClineMessages ran first), the + // failure must only be logged, and finalize must still resolve so callers' + // error-path cleanup (diff-view reset, resetTaskPartialState) always completes. + const boom = new Error("updateClineMessage boom") + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockImplementation(async () => { + throw boom + }) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const partialToolAsk = { + ts: Date.now() - 1, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: true, + } + + task.clineMessages.push(partialToolAsk) + + await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() + await flushMicrotasks() + + expect(partialToolAsk.partial).toBe(false) + expect(task.clineMessages[0].isAnswered).toBe(true) + expect(saveSpy).toHaveBeenCalled() + expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#finalizePartialToolAsk] updateClineMessage failed:", + boom, + ) + + updateSpy.mockRestore() + saveSpy.mockRestore() + }) + + it("finalizePartialToolAsk logs and skips the webview update when persistence fails", async () => { + // Pins the saveClineMessages-failure guard in finalizePartialToolAsk: while the + // on-disk record still carries partial: true, a webview-only update would diverge + // the two views (a later state resync or restart reload would flip the spinner + // back on). finalize must log, skip updateClineMessage, and still resolve so + // callers' error-path cleanup completes. + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(false) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockResolvedValue(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const partialToolAsk = { + ts: Date.now() - 1, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: true, + } + + task.clineMessages.push(partialToolAsk) + + await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() + await flushMicrotasks() + + // The in-memory message is still finalized so the ask is resolved by the system... + expect(partialToolAsk.partial).toBe(false) + expect(task.clineMessages[0].isAnswered).toBe(true) + // ...and the persistence failure is observed instead of silently swallowed. + expect(saveSpy).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update", + ) + // ...but the webview update is skipped so disk (still partial: true) and + // webview do not diverge until the next save repairs the record. + expect(updateSpy).not.toHaveBeenCalled() + + updateSpy.mockRestore() + saveSpy.mockRestore() + }) + + it("finalizePartialToolAsk still updates the webview when a later save stage fails", async () => { + // saveClineMessages() reports the message write separately from the metadata / + // task-history stages: the message array persisted while a later stage failed + // must still count as a successful save, so the finalized ask reaches the + // webview. A stale metadata entry is recomputed and re-emitted by the next + // saveClineMessages() call. + // saveTaskMessages() persists through safeWriteJson, which only mocks the + // fs/promises write helpers: its real fs.access gate and lockfile need the + // task directory to exist (uuid v7 is mocked to the fixed id below). + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockResolvedValue(undefined) + const metadataFailure = new Error("task history stage failed") + const historySpy = vi.spyOn(mockProvider, "updateTaskHistory").mockRejectedValueOnce(metadataFailure) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const partialToolAsk = { + ts: Date.now() - 1, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: true, + } + + task.clineMessages.push(partialToolAsk) + + await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() + await flushMicrotasks() + + expect(partialToolAsk.partial).toBe(false) + expect(task.clineMessages[0].isAnswered).toBe(true) + // The message array persisted, so the webview update must run even though a + // later save stage failed... + expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) + // ...and the later-stage failure is observed instead of silently swallowed. + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save task metadata:", expect.any(Error)) + + updateSpy.mockRestore() + historySpy.mockRestore() + }) + + it("finalizePartialToolAsk skips the webview update when the message write itself fails", async () => { + // Complements the later-stage-failure test above by failing the first save + // stage: with the real task directory removed, safeWriteJson's fs.access + // throws before anything is persisted, saveClineMessages() reports the + // failed message write, and the skip guard keeps the webview update off. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockResolvedValue(undefined) + try { + fsReal.rmSync(taskDir, { recursive: true, force: true }) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const partialToolAsk = { + ts: Date.now() - 1, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: true, + } + + task.clineMessages.push(partialToolAsk) + + await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() + await flushMicrotasks() + + // The in-memory ask is still finalized... (the flags are set before saving) + expect(partialToolAsk.partial).toBe(false) + expect(task.clineMessages[0].isAnswered).toBe(true) + // ...but the failed message write skips the webview update and surfaces + // both failure logs instead of updating on an unpersisted save. + expect(updateSpy).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save Roo messages:", expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update", + ) + } finally { + // Restore the shared directory for sibling tests that persist through the + // real fs, even when the operation rejects or an assertion fails. + fsReal.mkdirSync(taskDir, { recursive: true }) + updateSpy.mockRestore() + } + }) + + it("finalizePartialToolAsk ignores partial asks that match only some predicate clauses", async () => { + // Each distractor below satisfies a strict subset of the findLast predicate + // clauses, so no single clause (or a wrong combination of clauses) may select + // it: partial, type, ask kind, and text must all hold together. + const updateSpy = vi + .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") + .mockResolvedValue(undefined) + const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const partialToolAsk: ClineMessage = { + ts: Date.now() - 4, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: true, + } + // Completed (non-partial) tool ask with the same text. + const completedToolAsk: ClineMessage = { + ts: Date.now() - 3, + type: "ask" as const, + ask: "tool" as const, + text: "partial tool message", + partial: false, + } + // Partial ask of a different kind. + const nonToolAsk: ClineMessage = { + ts: Date.now() - 2, + type: "ask" as const, + ask: "completion_result" as const, + text: "partial tool message", + partial: true, + } + // Deliberately malformed: a "say" message that still carries the tool ask + // fields. The ClineMessage schema allows both fields, and the predicate under + // test reads message.ask on any message, so this is exactly the distractor + // the type clause exists to filter out. + const sayWithToolAsk: ClineMessage = { + ts: Date.now() - 1, + type: "say" as const, + say: "error" as const, + text: "partial tool message", + partial: true, + ask: "tool" as const, + } + + task.clineMessages.push(partialToolAsk) + task.clineMessages.push(completedToolAsk) + task.clineMessages.push(nonToolAsk) + task.clineMessages.push(sayWithToolAsk) + + await task.finalizePartialToolAsk("partial tool message") + await flushMicrotasks() + + expect(partialToolAsk.partial).toBe(false) + expect(partialToolAsk.isAnswered).toBe(true) + // Every distractor stays untouched: only the genuine partial tool ask is + // finalized. + expect(completedToolAsk.partial).toBe(false) + expect(completedToolAsk.isAnswered).toBeUndefined() + expect(nonToolAsk.partial).toBe(true) + expect(nonToolAsk.isAnswered).toBeUndefined() + expect(sayWithToolAsk.partial).toBe(true) + expect(saveSpy).toHaveBeenCalledTimes(1) + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) + + updateSpy.mockRestore() + saveSpy.mockRestore() + }) + it("logs (instead of crashing) when updateClineMessage rejects from the ask() ignore-partial path", async () => { // Pins the .catch arm on the fire-and-forget updateClineMessage call // in ask() when a new partial ask arrives while the previous partial From d172c95ae3e451b47c90f2ae829f7674e8483e4c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:24 +0800 Subject: [PATCH 02/29] feat(write-to-file): per-task partial stream state + cleanup primitives --- .../removeClineFromStack-delegation.spec.ts | 41 +++++ src/core/tools/WriteToFileTool.ts | 165 +++++++++++++++++- .../tools/__tests__/writeToFileTool.spec.ts | 116 ++++++++++++ src/core/webview/ClineProvider.ts | 6 + 4 files changed, 326 insertions(+), 2 deletions(-) diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index bc5b8a4426..b42a549fef 100644 --- a/src/__tests__/removeClineFromStack-delegation.spec.ts +++ b/src/__tests__/removeClineFromStack-delegation.spec.ts @@ -5,6 +5,7 @@ import { ClineProvider } from "../core/webview/ClineProvider" import { TaskRegistry } from "../core/task/TaskRegistry" import { PendingActionSettlementError, type Task } from "../core/task/Task" import { makeProviderStub } from "./helpers/provider-stub" +import { writeToFileTool } from "../core/tools/WriteToFileTool" type MockTask = Pick & Partial> & { @@ -208,6 +209,46 @@ describe("ClineProvider failed history restoration cleanup", () => { expect(task.dispose).toHaveBeenCalledOnce() }) + it("releases the tool's per-task state before a directly disposed task loses its listeners", async () => { + const key = "failed-history-task.inst-1" + const order: string[] = [] + const task = { + taskId: "failed-history-task", + instanceId: "inst-1", + emit: vi.fn(), + once: vi.fn(), + off: vi.fn(), + dispose: vi.fn().mockImplementation(() => { + // Dispose removes every listener, so this is the last moment the abort + // cleanup could still have run; the entry must already be gone here. + order.push(writeToFileTool["taskPartialStreamState"].has(key) ? "state-retained" : "state-cleared") + order.push("dispose") + return Promise.resolve() + }), + } as unknown as Task + // Fixture: the state entry the tool would have created during a partial stream. + writeToFileTool["taskPartialStreamState"].set(key, { + lastSeenPartialPath: undefined, + streamFailed: false, + streamError: undefined, + task, + abortCleanup: () => {}, + }) + const taskEventListeners = new Map([[task, [vi.fn()]]]) + const taskRegistry = new TaskRegistry() + taskRegistry.push(task) + const provider = { taskRegistry, taskEventListeners, log: vi.fn() } as unknown as ClineProvider + + await privateClineProvider.cleanupFailedHistoryTask.call( + provider, + task, + new PendingActionSettlementError("settlement failed"), + ) + + expect(order).toEqual(["state-cleared", "dispose"]) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + it("keeps the task active after an unrelated history resume failure", async () => { const cleanupListener = vi.fn() const task = { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..c9776553b7 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -2,7 +2,7 @@ import path from "path" import delay from "delay" import fs from "fs/promises" -import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS, RooCodeEventName } from "@roo-code/types" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" @@ -23,9 +23,165 @@ interface WriteToFileParams { content: string } +/** + * Per-task partial-streaming state tracked by WriteToFileTool. + */ +interface TaskPartialStreamState { + /** Last path seen during streaming; undefined until the first delta. */ + lastSeenPartialPath: string | undefined + /** True once a streaming delta hit a fatal filesystem error. */ + streamFailed: boolean + /** The original filesystem error of the failed streaming delta, reported once + * by onParameterParseFailure() when the final block fails to parse (so + * execute() never runs and would never report it). */ + streamError: Error | undefined + /** The task that owns this state; target for abort-listener deregistration. */ + task: Task + /** TaskAborted listener that tears this state down; registered once per task. */ + abortCleanup: () => void +} + export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const + /** + * Per-task partial-streaming state, keyed by task id (taskId + instanceId). + * + * All per-task fields live in one object per task so that resetTaskPartialState() / + * resetPartialState() cannot clear a subset of them and leak the rest (abort + * listener, failure mark, path-stabilization entry) for an abandoned stream. + * + * This deliberately diverges from the sibling streaming tools (ApplyDiffTool, + * EditFileTool, SearchReplaceTool, EditTool), which rely on BaseTool's singleton + * lastSeenPartialPath / resetPartialState and keep no failure state. The divergence is + * intentional, for two reasons: + * + * 1. Only this tool's handlePartial performs failure-prone streaming work + * (diffViewProvider.open/update, which can throw EACCES/EROFS); the siblings only + * send a task.ask preview. Without per-task failure tracking, every later delta for + * a failed path would re-attempt the failing operation and re-spawn a partial tool + * message. + * + * 2. The tool instance is a module-level singleton shared by every task, including + * tasks from different ClineProvider instances (e.g. sidebar and tab-panel + * providers, which activate independently). A single provider streams at most one + * task at a time — TaskScheduler gates task.run() at maxConcurrency=1 and + * delegation disposes the parent before the child starts — so per-task keying is + * reachable specifically across providers, where two providers can stream + * write_to_file concurrently through this same singleton. + * + * Lifting this per-task keying into BaseTool for all streaming tools is a follow-up + * (separate PR); it is deliberately not done here. + */ + private taskPartialStreamState = new Map() + + private getPartialStreamFailureKey(task: Task): string { + return `${task.taskId}.${task.instanceId}` + } + + /** + * Get this task's partial stream state, creating it on first use and registering the + * TaskAborted teardown listener exactly once per task. + */ + private getTaskPartialStreamState(task: Task): TaskPartialStreamState { + const key = this.getPartialStreamFailureKey(task) + const existing = this.taskPartialStreamState.get(key) + if (existing) { + return existing + } + + const state: TaskPartialStreamState = { + lastSeenPartialPath: undefined, + streamFailed: false, + streamError: undefined, + task, + abortCleanup: () => this.resetTaskPartialState(task), + } + this.taskPartialStreamState.set(key, state) + task.once(RooCodeEventName.TaskAborted, state.abortCleanup) + return state + } + + private hasPathStabilizedForTask(state: TaskPartialStreamState, partialPath: string | undefined): boolean { + // Stryker disable next-line ConditionalExpression: the `!== undefined` clause is redundant: when + // lastSeenPartialPath is undefined, the second clause only matches an undefined partialPath, which + // the `!!partialPath` in the return value rejects either way -- no test can distinguish the two. + const pathHasStabilized = state.lastSeenPartialPath !== undefined && state.lastSeenPartialPath === partialPath + state.lastSeenPartialPath = partialPath + return pathHasStabilized && !!partialPath + } + + /** + * Clear a task's partial-stream state from a disposal path that does not abort first. + * Task.dispose() removes every listener, so a task disposed directly (for example + * ClineProvider.cleanupFailedHistoryTask()) never fires the TaskAborted cleanup and + * this singleton would keep the disposed task and its diff-view provider. + */ + public clearTaskState(task: Task): void { + this.resetTaskPartialState(task) + } + + private resetTaskPartialState(task: Task): void { + const key = this.getPartialStreamFailureKey(task) + const state = this.taskPartialStreamState.get(key) + if (!state) { + return + } + state.task.off(RooCodeEventName.TaskAborted, state.abortCleanup) + this.taskPartialStreamState.delete(key) + } + + private async resetDiffViewAfterWrite(task: Task): Promise { + await task.diffViewProvider.reset().catch((resetError) => { + console.error("Error resetting write_to_file diff view:", resetError) + }) + } + + /** + * Restore the diff editor document to its pre-streaming state and close the view. + * + * reset() clears the provider's state but leaves the diff document dirty with the + * streamed content; a user save would then persist a write the task never completed + * (denied or failed before approval). Must run BEFORE resetDiffViewAfterWrite(), + * since reset() clears the state revertChanges() relies on. No-op when no diff view + * is open. Failures are logged and swallowed so the remaining cleanup (reset, + * per-task state teardown) always continues. + */ + private async revertDiffChangesBeforeReset(task: Task): Promise { + await task.diffViewProvider.revertChanges().catch((revertError) => { + console.error("Error reverting write_to_file diff view changes:", revertError) + }) + } + + private async finalizePartialToolAskAfterFailure(task: Task, text?: string): Promise { + await task.finalizePartialToolAsk(text).catch((finalizeError) => { + console.error("Error finalizing write_to_file partial tool ask:", finalizeError) + }) + } + + /** + * Teardown boundary for the handle() parse-failure path, where execute() never + * runs and therefore its finally (resetTaskPartialState) never runs either. + * + * Tears down the per-task stream state: otherwise the abort listener leaks for + * the task's lifetime, and when a streaming delta had failed, the streamFailed + * guard would suppress the diff preview of every later write_to_file in this + * task. Restores the diff document: streaming may have opened it with + * unapproved partial content, and execute()'s error cleanup (revert + reset) + * never fires on this path, so a user save could persist the content without + * the teardown here. When a streaming delta already hit a fatal filesystem + * error, that error is what the user can act on, so report it with the same + * "writing file" context execute()'s catch uses, and suppress the incidental + * parse error. + */ + override resetPartialState(): void { + super.resetPartialState() + for (const state of this.taskPartialStreamState.values()) { + state.task.off(RooCodeEventName.TaskAborted, state.abortCleanup) + } + this.taskPartialStreamState.clear() + } + async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise { const { pushToolResult, handleError, askApproval } = callbacks const relPath = params.path @@ -197,8 +353,13 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const relPath: string | undefined = block.params.path const newContent: string | undefined = block.params.content + + // Get (or create) this task's state; registers the TaskAborted teardown listener + // once, so abandoned streams are torn down even if execute() never runs. + const partialStreamState = this.getTaskPartialStreamState(task) + // Wait for path to stabilize before showing UI (prevents truncated paths) - if (!this.hasPathStabilized(relPath) || newContent === undefined) { + if (!this.hasPathStabilizedForTask(partialStreamState, relPath) || newContent === undefined) { return } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..bec63332a7 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -1,5 +1,6 @@ import * as path from "path" +import { RooCodeEventName } from "@roo-code/types" import type { MockedFunction } from "vitest" import { fileExistsAtPath, createDirectoriesForFile } from "../../../utils/fs" @@ -118,6 +119,9 @@ describe("writeToFileTool", () => { mockedPathResolve.mockReturnValue(absoluteFilePath) mockedFileExistsAtPath.mockResolvedValue(false) + // vi.clearAllMocks() keeps the last mock implementation; reset the factory default here + // so no test depends on declaration order or an earlier test's rejection. + mockedCreateDirectoriesForFile.mockResolvedValue([]) mockedIsPathOutsideWorkspace.mockReturnValue(false) mockedGetReadablePath.mockReturnValue("test/path.txt") mockedUnescapeHtmlEntities.mockImplementation((content) => { @@ -128,6 +132,8 @@ describe("writeToFileTool", () => { return content }) + mockCline.taskId = "task-1" + mockCline.instanceId = "instance-1" mockCline.cwd = "/" mockCline.consecutiveMistakeCount = 0 mockCline.didEditFile = false @@ -186,8 +192,12 @@ describe("writeToFileTool", () => { } mockCline.say = vi.fn().mockResolvedValue(undefined) mockCline.ask = vi.fn().mockResolvedValue(undefined) + mockCline.once = vi.fn() + mockCline.off = vi.fn() + mockCline.finalizePartialToolAsk = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") + mockCline.processQueuedMessages = vi.fn() mockAskApproval = vi.fn().mockResolvedValue(true) mockHandleError = vi.fn().mockResolvedValue(undefined) @@ -419,6 +429,111 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) }) + + it("cleans per-task partial state when the task aborts before execute finalization", async () => { + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + expect(mockCline.once).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, expect.any(Function)) + + abortCleanup?.() + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(2) + }) + + it("does not treat a changed path between deltas as stabilized", async () => { + // Delta 1 streams "alpha.txt"; delta 2 streams "beta.txt" for the same task. The path changed + // between deltas, so it must not count as stabilized and no partial `tool` ask may be issued for + // the still-changing second path. + await executeWriteFileTool({ path: "alpha.txt" }, { isPartial: true }) + await executeWriteFileTool({ path: "beta.txt" }, { isPartial: true }) + + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + }) + + + + + + }) + + describe("path stabilization predicate", () => { + // The predicate is exercised directly (it is private) because not all of its branches are + // observable through handlePartial(): an undefined path reaches the same early return either + // way, so the clause-by-clause behavior must be pinned at the predicate level. + function makeState(lastSeenPartialPath: string | undefined) { + return { + lastSeenPartialPath, + streamFailed: false, + streamError: undefined, + task: mockCline, + abortCleanup: () => {}, + } + } + + it("reports a first delta as not stabilized and records the seen path", () => { + const state = makeState(undefined) + + expect(writeToFileTool["hasPathStabilizedForTask"](state, "a.txt")).toBe(false) + expect(state.lastSeenPartialPath).toBe("a.txt") + }) + + it("reports a repeated path as stabilized", () => { + const state = makeState("a.txt") + + expect(writeToFileTool["hasPathStabilizedForTask"](state, "a.txt")).toBe(true) + }) + + it("reports a changed path as not stabilized", () => { + const state = makeState("a.txt") + + expect(writeToFileTool["hasPathStabilizedForTask"](state, "b.txt")).toBe(false) + expect(state.lastSeenPartialPath).toBe("b.txt") + }) + }) + + describe("resetPartialState", () => { + it("resets the base partial path and detaches every task's abort listener", async () => { + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + + // Seed one per-task state with an abort listener attached. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + expect(abortCleanup).toBeTypeOf("function") + + // The base-class singleton field is reset by super.resetPartialState(). + writeToFileTool["lastSeenPartialPath"] = "stale-path" + writeToFileTool.resetPartialState() + + expect(writeToFileTool["lastSeenPartialPath"]).toBeUndefined() + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + + // The per-task map was cleared too: a fresh delta sequence starts un-stabilized, so no + // second partial ask is issued. + await executeWriteFileTool({}, { isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + }) }) describe("user interaction", () => { @@ -470,6 +585,7 @@ describe("writeToFileTool", () => { // Second call with same path - path is now stabilized, error occurs await executeWriteFileTool({}, { isPartial: true }) expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) + }) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 874e2f8f08..75612323f9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -60,6 +60,7 @@ import { } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" +import { writeToFileTool } from "../tools/WriteToFileTool" import { TaskScheduler } from "../task/TaskScheduler" import { getEffectiveTaskApiConfiguration, @@ -636,6 +637,11 @@ export class ClineProvider this.taskEventListeners.delete(task) } + // Dispose removes all listeners, so the tool's per-task state must be released + // while the task is still here; otherwise the singleton keeps the disposed task + // and its diff-view provider. + writeToFileTool.clearTaskState(task) + try { await task.dispose() } catch (error) { From 52699c6cdc7d90e52d6ad60ad64dc2e3cb9982e2 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:24 +0800 Subject: [PATCH 03/29] test(write-to-file): cover partial-state cleanup primitives directly --- ...teToFileTool-partial-state-cleanup.spec.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts new file mode 100644 index 0000000000..4506742c26 --- /dev/null +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -0,0 +1,100 @@ +// npx vitest run core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts + +import { RooCodeEventName } from "@roo-code/types" +import { vi, type MockedFunction } from "vitest" + +import { type Task } from "../../task/Task" +import { writeToFileTool } from "../WriteToFileTool" + +// The cleanup primitives only read these members, so a structural double is enough; +// the double assertion is the repo's existing pattern for private-method tests +// (see src/__tests__/removeClineFromStack-delegation.spec.ts). +interface CleanupTask { + taskId: string + instanceId: string + once: MockedFunction<(...args: unknown[]) => unknown> + off: MockedFunction<(...args: unknown[]) => unknown> + diffViewProvider: { + reset: MockedFunction<() => Promise> + revertChanges: MockedFunction<() => Promise> + } + finalizePartialToolAsk: MockedFunction<() => Promise> +} + +function buildTask(taskId: string, instanceId: string): Task { + const task: CleanupTask = { + taskId, + instanceId, + once: vi.fn(), + off: vi.fn(), + diffViewProvider: { + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + }, + finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), + } + return task as unknown as Task +} + +// Private members are reached by bracket notation (AGENTS.md: no `as any`). +const stateFor = (task: Task) => writeToFileTool["taskPartialStreamState"].get(`${task.taskId}.${task.instanceId}`) + +describe("WriteToFileTool per-task partial-state cleanup", () => { + afterEach(() => { + writeToFileTool["taskPartialStreamState"].clear() + vi.restoreAllMocks() + }) + + it("releases the task state and deregisters the abort listener", async () => { + const task = buildTask("cleanup-task", "inst-1") + const state = writeToFileTool["getTaskPartialStreamState"](task) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + + writeToFileTool.clearTaskState(task) + + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect((task as unknown as CleanupTask).off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, state.abortCleanup) + }) + + it("is a no-op for a task that never streamed", async () => { + const task = buildTask("never-streamed", "inst-2") + + writeToFileTool.clearTaskState(task) + + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect((task as unknown as CleanupTask).off).not.toHaveBeenCalled() + }) + + it("logs and continues when resetting the diff view fails", async () => { + const task = buildTask("reset-fails", "inst-3") + const t = task as unknown as CleanupTask + t.diffViewProvider.reset = vi.fn().mockRejectedValue(new Error("reset failed")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await writeToFileTool["resetDiffViewAfterWrite"](task) + + expect(errorSpy).toHaveBeenCalledWith("Error resetting write_to_file diff view:", expect.any(Error)) + }) + + it("logs and continues when reverting the diff document fails", async () => { + const task = buildTask("revert-fails", "inst-4") + const t = task as unknown as CleanupTask + t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await writeToFileTool["revertDiffChangesBeforeReset"](task) + + expect(errorSpy).toHaveBeenCalledWith("Error reverting write_to_file diff view changes:", expect.any(Error)) + }) + + it("logs and continues when finalizing the open partial ask fails", async () => { + const task = buildTask("finalize-fails", "inst-5") + const t = task as unknown as CleanupTask + t.finalizePartialToolAsk = vi.fn().mockRejectedValue(new Error("finalize failed")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await writeToFileTool["finalizePartialToolAskAfterFailure"](task, "partial text") + + expect(errorSpy).toHaveBeenCalledWith("Error finalizing write_to_file partial tool ask:", expect.any(Error)) + }) +}) From 4b23b6a270aa34368963613e145bb410c0fcd567 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:24 +0800 Subject: [PATCH 04/29] fix(write-to-file): capture streaming failure once, report it once --- src/core/tools/WriteToFileTool.ts | 51 +++- .../tools/__tests__/writeToFileTool.spec.ts | 232 +++++++++++++++++- 2 files changed, 267 insertions(+), 16 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index c9776553b7..267c728c27 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -353,6 +353,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const relPath: string | undefined = block.params.path const newContent: string | undefined = block.params.content + const partialStreamFailureKey = this.getPartialStreamFailureKey(task) + + // A prior streaming delta for this task already hit a fatal filesystem error. + // Skip further streaming work so we don't create a new partial tool message on every + // subsequent delta. execute() will report the error once when the block completes. + if (this.taskPartialStreamState.get(partialStreamFailureKey)?.streamFailed) { + return + } // Get (or create) this task's state; registers the TaskAborted teardown listener // once, so abandoned streams are torn down even if execute() never runs. @@ -385,12 +393,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.diffViewProvider.editType = fileExists ? "modify" : "create" } - // Create parent directories early for new files to prevent ENOENT errors - // in subsequent operations (e.g., diffViewProvider.open) - if (!fileExists) { - await createDirectoriesForFile(absolutePath) - } - const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -406,14 +408,37 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await task.ask("tool", partialMessage, block.partial).catch(() => {}) if (newContent) { - if (!task.diffViewProvider.isEditing) { - await task.diffViewProvider.open(relPath!) - } + try { + if (!task.diffViewProvider.isEditing) { + await task.diffViewProvider.open(relPath!) + } - await task.diffViewProvider.update( - everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, - false, - ) + await task.diffViewProvider.update( + everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, + false, + ) + } catch (error) { + // Opening or updating the diff view can throw on filesystem errors + // (EACCES/EROFS on read-only paths). Finalize the partial tool message + // so the UI spinner doesn't get stuck and reset the diff view. Do NOT + // rethrow: the same filesystem operation is retried in execute() once the + // block completes, and that authoritative non-partial path reports the + // error to the user. Surfacing it here too would show the same error twice. + // Swallowing it here is safe because the agent loop advances naturally when + // the non-partial block arrives (it does not depend on this throw). + console.error(`Error streaming write_to_file diff view:`, error) + // Mark the stream as failed so later deltas don't re-attempt and spawn a new + // partial tool message each time. Retain the original error: if the final + // block later fails to parse, execute() never runs and only + // onParameterParseFailure() can report this failure to the user. + partialStreamState.streamFailed = true + partialStreamState.streamError = error instanceof Error ? error : new Error(String(error)) + await this.finalizePartialToolAskAfterFailure(task, partialMessage) + // The write was never approved: restore the document so a user save cannot + // persist the failed streamed content (reset() alone leaves it dirty). + await this.revertDiffChangesBeforeReset(task) + await this.resetDiffViewAfterWrite(task) + } } } } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index bec63332a7..f4677277d7 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -97,6 +97,19 @@ describe("writeToFileTool", () => { const testContent = "Line 1\nLine 2\nLine 3" const testContentWithMarkdown = "```javascript\nLine 1\nLine 2\n```" + // The exact payload handlePartial() streams as the partial `tool` ask for the default + // test scenario (new file, readable path, in-workspace, not write-protected). + // finalizePartialToolAsk() no-ops on a text mismatch, so finalize assertions must + // match this exactly: a weaker matcher (e.g. expect.any(String), which a relPath also + // satisfies) would pass a mutant that passes the wrong text and leaves the spinner stuck. + const expectedPartialToolMessage = JSON.stringify({ + tool: "newFileCreated", + path: "test/path.txt", + content: testContent, + isOutsideWorkspace: false, + isProtected: false, + }) + // Mocked functions with correct types const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction @@ -429,6 +442,25 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) }) + it("does not share path stabilization between tasks with the same path", async () => { + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).not.toHaveBeenCalled() + + mockCline.taskId = "task-2" + mockCline.instanceId = "instance-2" + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).not.toHaveBeenCalled() + + mockCline.taskId = "task-1" + mockCline.instanceId = "instance-1" + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + + mockCline.taskId = "task-2" + mockCline.instanceId = "instance-2" + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(2) + }) it("cleans per-task partial state when the task aborts before execute finalization", async () => { let abortCleanup: (() => void) | undefined @@ -465,8 +497,49 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() }) + it("does not issue a partial ask when content is undefined after path stabilization", async () => { + // Delta 1 stabilizes the path. Delta 2 repeats it but carries no content yet: the + // `newContent === undefined` clause must short-circuit the ask even though the path itself has + // stabilized. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({ content: undefined }, { isPartial: true }) + + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() + }) + + it("does not reopen an already open diff view during streaming", async () => { + // The diff view is already open for this task (isEditing). A stabilized delta must still update + // the streamed content but must not call open() again -- reopening would discard the view's + // current state. + mockCline.diffViewProvider.isEditing = true + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + + expect(mockCline.ask).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) + }) + it("logs the streaming diff view failure with the write_to_file context", async () => { + // The catch arm logs a context-specific message before swallowing the error (execute() reports + // the authoritative one). The message must keep the write_to_file context so the log is + // actionable. + mockCline.diffViewProvider.open.mockRejectedValue(new Error("EACCES: permission denied")) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error streaming write_to_file diff view:", + expect.anything(), + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) @@ -575,17 +648,170 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() }) - it("handles partial streaming errors after path stabilizes", async () => { + + + it("swallows partial streaming errors instead of surfacing a duplicate error bubble", async () => { + // The same filesystem operation is retried in execute() once the block completes, + // and that authoritative non-partial path reports the error to the user. Surfacing + // it during streaming too would show the same error twice, so handlePartial must NOT + // route streaming errors through handleError. mockCline.diffViewProvider.open.mockRejectedValue(new Error("Open failed")) // First call - path not yet stabilized, no error yet await executeWriteFileTool({}, { isPartial: true }) expect(mockHandleError).not.toHaveBeenCalled() - // Second call with same path - path is now stabilized, error occurs + // Second call with same path - path is now stabilized, error occurs but is swallowed + await executeWriteFileTool({}, { isPartial: true }) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("finalizes partial tool message and resets diff view when handlePartial open() fails", async () => { + // Regression test: when diffViewProvider.open() throws during streaming (e.g. EACCES/EROFS + // on a read-only path), the partial tool ask created at the top of handlePartial leaves the + // UI spinner stuck. handlePartial must finalize the partial message and reset the diff view, + // and must NOT surface a duplicate error (execute() reports the authoritative one). + mockCline.diffViewProvider.open.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, open '/ro/test.py'"), { code: "EACCES" }), + ) + // Record the relative order of revertChanges() and reset() (vitest mocks expose + // no invocationCallOrder). + const diffViewCallOrder: string[] = [] + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + + // First call - path not yet stabilized await executeWriteFileTool({}, { isPartial: true }) - expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) + expect(mockCline.finalizePartialToolAsk).not.toHaveBeenCalled() + // Second call - path stabilized, open() rejects + await executeWriteFileTool({}, { isPartial: true }) + + // Exact streamed payload: finalizePartialToolAsk() no-ops on a text mismatch, so + // a wrong argument (e.g. relPath) would leave the spinner stuck. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) + // The failed write's streamed content must be reverted before reset() clears the + // state revertChanges() relies on. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(mockHandleError).not.toHaveBeenCalled() }) + + it("finalizes partial tool message and resets diff view when handlePartial update() fails", async () => { + // Same regression as above but for the streaming update() call failing after open() succeeds. + mockCline.diffViewProvider.update.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + // Record the relative order of revertChanges() and reset() (vitest mocks expose + // no invocationCallOrder). + const diffViewCallOrder: string[] = [] + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + + // First call - path not yet stabilized + await executeWriteFileTool({}, { isPartial: true }) + + // Second call - path stabilized, update() rejects + await executeWriteFileTool({}, { isPartial: true }) + + // Exact streamed payload: finalizePartialToolAsk() no-ops on a text mismatch, so + // a wrong argument (e.g. relPath) would leave the spinner stuck. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) + // The failed write's streamed content must be reverted before reset() clears the + // state revertChanges() relies on. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("does not spawn a new partial tool message on each streaming delta after a failure", async () => { + // Regression test: after diffViewProvider.open() throws and the partial message is + // finalized + diff view reset, the next streaming delta saw a non-partial last message + // and created a brand new "Zoo wants to edit this file" message -- repeating once per + // delta. After the fix, partialStreamFailed short-circuits subsequent deltas so only + // the single initial partial ask is issued. + mockCline.diffViewProvider.open.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, mkdir '/scratch'"), { code: "EROFS" }), + ) + + // Delta 1 - stabilize path (no ask yet) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + // Delta 2 - path stabilized, ask issued once, open() fails, stream marked failed + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + // Deltas 3..5 - must be short-circuited, no further asks + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + // Only the single partial ask from delta 2 should have been issued + expect(mockCline.ask).toHaveBeenCalledTimes(1) + // open() must not be retried after the first failure + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + }) + + + + + + + + + + it("keeps partial stream failures isolated per task", async () => { + mockCline.diffViewProvider.open.mockRejectedValueOnce( + Object.assign(new Error("EROFS: read-only file system, mkdir '/task-a'"), { code: "EROFS" }), + ) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + + mockCline.taskId = "task-2" + mockCline.instanceId = "instance-2" + mockCline.diffViewProvider.open.mockResolvedValue(undefined) + mockCline.diffViewProvider.update.mockResolvedValue(undefined) + mockCline.diffViewProvider.editType = undefined + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.ask).toHaveBeenCalledTimes(2) + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(2) + }) + + + + it("EROFS in handlePartial does not stall agent loop -- createDirectoriesForFile is not called", async () => { + // Regression test: before the fix, createDirectoriesForFile was called in handlePartial + // with no .catch() guard. An EROFS throw escaped to BaseTool.handle(), which called + // handleError but did not set didRejectTool/didAlreadyUseTool, so the advancement gate + // in presentAssistantMessage was never reached and the agent loop stalled permanently. + // After the fix the call is removed entirely -- handlePartial never touches the filesystem. + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, mkdir '/scratch'"), { code: "EROFS" }), + ) + + // First call -- path not yet stabilized, returns early + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockHandleError).not.toHaveBeenCalled() + + // Second call -- path stabilized; createDirectoriesForFile must NOT be called from + // handlePartial, so the mock rejection must not trigger and handleError must not be called + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + + + }) }) From e3c10401f760a33cb977cd3cfdc1f1af8de49d3f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:25 +0800 Subject: [PATCH 05/29] feat(tools): onParameterParseFailure teardown boundary --- src/core/tools/BaseTool.ts | 39 +++- src/core/tools/WriteToFileTool.ts | 22 ++ .../tools/__tests__/writeToFileTool.spec.ts | 192 ++++++++++++++++++ 3 files changed, 250 insertions(+), 3 deletions(-) diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 83a733c7b0..dc16a9d81d 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -155,9 +155,23 @@ export abstract class BaseTool { throw new Error("Tool call is missing native arguments (nativeArgs).") } } catch (error) { - console.error(`Error parsing parameters:`, error) - const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}` - await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage)) + const parseError = error instanceof Error ? error : new Error(String(error)) + console.error(`Error parsing parameters:`, parseError) + // Final args could not be parsed (e.g. the model's tool call was truncated + // mid-JSON by the output token limit), so execute() will never run. If a + // streaming delta already opened a partial "tool" ask (partial: true), + // finalize it here or the webview spinner stays stuck indefinitely. + await task.finalizePartialToolAsk().catch((finalizeError) => { + console.error(`Error finalizing ${this.name} partial tool ask:`, finalizeError) + }) + // execute() never runs on this path, so tools that keep per-task state + // outside execute() (streaming failure marks, abort listeners) get their + // one remaining teardown boundary here. + const reportedStreamingFailure = await this.onParameterParseFailure(task, callbacks, parseError) + if (!reportedStreamingFailure) { + const errorMessage = `Failed to parse ${this.name} parameters: ${parseError.message}` + await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage)) + } // Note: handleError already emits a tool_result via formatResponse.toolError in the caller. // Do NOT call pushToolResult here to avoid duplicate tool_result payloads. return @@ -166,4 +180,23 @@ export abstract class BaseTool { // Execute with typed parameters await this.execute(params, task, callbacks) } + + /** + * Teardown boundary for the native-argument parse-failure path in handle(). + * + * When nativeArgs are missing or malformed, execute() never runs, so per-task + * state a tool registered outside execute() (streaming failure marks, abort + * listeners) is never torn down there. Streaming tools override this to tear + * that state down and, when a streaming delta already failed, to report the + * captured streaming error instead of the generic parse error. + * + * @param task - Task instance + * @param callbacks - Tool execution callbacks + * @param parseError - The native-argument parse error + * @returns true when the override already reported the failure to the user, + * so handle() suppresses the generic parse error + */ + protected async onParameterParseFailure(task: Task, callbacks: ToolCallbacks, parseError: Error): Promise { + return false + } } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 267c728c27..ba25a9f2c0 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -174,6 +174,28 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * "writing file" context execute()'s catch uses, and suppress the incidental * parse error. */ + override async onParameterParseFailure(task: Task, callbacks: ToolCallbacks, parseError: Error): Promise { + const state = this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) + if (!state) { + return false + } + this.resetTaskPartialState(task) + // Streaming may have opened the diff view with unapproved partial content. + // execute() never runs on this path, so its error cleanup (revert + reset) + // never fires: restore the document here so a user save cannot persist + // content the write never completed (the same invariant the denial and + // streaming-failure paths maintain). Both helpers no-op when no view is + // open. + await this.revertDiffChangesBeforeReset(task) + await this.resetDiffViewAfterWrite(task) + if (!state.streamError) { + return false + } + void parseError + await callbacks.handleError("writing file", state.streamError) + return true + } + override resetPartialState(): void { super.resetPartialState() for (const state of this.taskPartialStreamState.values()) { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index f4677277d7..dddd139011 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -541,7 +541,123 @@ describe("writeToFileTool", () => { } }) + it("reports the captured streaming error instead of the parse error when the final block fails to parse, and clears the per-task state", async () => { + // A streaming delta fails with a filesystem error (streamFailed + streamError are + // captured). The final block then arrives without nativeArgs, so execute() never + // runs: the parse-failure teardown boundary must report the original filesystem + // error under the "writing file" context (not the incidental parse error) and tear + // down the per-task state, so the next write_to_file stream in this task is not + // blocked by the stale streamFailed guard and no abort listener leaks. + const fsError = new Error("EACCES: permission denied") + mockCline.diffViewProvider.open.mockRejectedValue(fsError) + // Capture the abort listener of the state created by the first deltas: it is the + // exact reference that the parse-failure teardown must detach. + let abortListener: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted && abortListener === undefined) { + abortListener = listener + } + return mockCline + }) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + const toolUse: ToolUse = { + type: "tool_use", + name: "write_to_file", + params: { path: testFilePath, content: testContent }, + nativeArgs: undefined, + partial: false, + } + await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: vi.fn(), + }) + + expect(mockHandleError).toHaveBeenCalledTimes(1) + expect(mockHandleError).toHaveBeenCalledWith("writing file", fsError) + expect(mockHandleError).not.toHaveBeenCalledWith( + expect.stringContaining("parsing write_to_file"), + expect.anything(), + ) + // The parse-failure teardown also restores the diff document: the + // streaming failure above already reverted it once (revert + reset), + // and the parse path runs the same cleanup again because execute() + // never runs on this path. + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(2) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(2) + // Per-task state torn down at this boundary: guard cleared, the exact + // registered abort listener detached. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(abortListener).toBeTypeOf("function") + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + + // The next stream for the same task must issue a partial ask again (the stale + // streamFailed guard is gone). + mockCline.diffViewProvider.open.mockResolvedValue(undefined) + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(2) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("restores the diff document when the final block fails to parse after successful streaming", async () => { + // Streaming opened the diff view with unapproved partial content (open and + // update both succeeded, so no streaming error was captured). The final block + // then arrives without nativeArgs: execute() never runs, so its error cleanup + // never fires. The parse-failure teardown must still restore the document + // (revert before reset) or a user save could persist content the write never + // completed, and must report the generic parse error (no streaming error to + // surface instead). + let abortListener: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted && abortListener === undefined) { + abortListener = listener + } + return mockCline + }) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + // Delta 1 - stabilize path; delta 2 - streams the partial content into the + // diff view (open + update resolve). + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.update).toHaveBeenCalledTimes(1) + + const toolUse: ToolUse = { + type: "tool_use", + name: "write_to_file", + params: { path: testFilePath, content: testContent }, + nativeArgs: undefined, + partial: false, + } + await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: vi.fn(), + }) + + // No streaming error was captured: the generic parse error is reported. + expect(mockHandleError).toHaveBeenCalledTimes(1) + expect(mockHandleError).toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + // The diff document is restored by the parse path itself (no streaming + // failure happened, so this is the only revert + reset in the test). + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(1) + // Per-task state torn down: guard cleared, exact listener detached. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(abortListener).toBeTypeOf("function") + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("path stabilization predicate", () => { @@ -755,7 +871,83 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) }) + it("finalizes any open partial tool ask when final args cannot be parsed", async () => { + // Regression test: a write_to_file block whose final args fail to parse (e.g. the + // tool call was truncated mid-JSON by the output token limit) never reaches + // execute(). A streaming delta for that block may already have opened a partial + // `tool` ask (partial: true) -- BaseTool.handle must finalize it, otherwise the + // UI spinner stays stuck even though the parse error bubble was shown. + // Delta 1 - stabilize path (no ask yet) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + // Delta 2 - path stabilized, partial ask issued once + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + expect(mockCline.finalizePartialToolAsk).not.toHaveBeenCalled() + // Final block arrives but its native args cannot be parsed, so execute() is skipped. + const toolUse: ToolUse = { + type: "tool_use", + name: "write_to_file", + params: { + path: testFilePath, + content: testContent, + }, + partial: false, + } + await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The parse error is still reported, and the open partial ask is finalized first. + // No argument: BaseTool.handle() calls finalizePartialToolAsk() with no text, so a + // mutation passing wrong text would leave findLast() unmatched and the spinner + // stuck. (toHaveBeenCalledWith(undefined) does not match a no-arg call under + // vitest's matcher semantics: [] is not equal to [undefined].) + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledTimes(1) + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith() + expect(mockHandleError).toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + }) + + it("continues parse failure cleanup when finalizing the partial ask fails", async () => { + // Pins the .catch arm on task.finalizePartialToolAsk() in BaseTool.handle(): when the + // final args cannot be parsed and finalizing the open partial ask also fails, the + // failure must only be logged so the parse error is still reported to the user. + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCline.finalizePartialToolAsk.mockRejectedValue(new Error("finalize failed")) + + // Final block arrives but its native args cannot be parsed, so execute() is skipped. + const toolUse: ToolUse = { + type: "tool_use", + name: "write_to_file", + params: { + path: testFilePath, + content: testContent, + }, + partial: false, + } + await writeToFileTool.handle(mockCline, toolUse as ToolUse<"write_to_file">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: vi.fn(), + }) + + // Same no-argument contract as the sibling test above: the finalize call in + // BaseTool.handle() carries no text. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledTimes(1) + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error finalizing write_to_file partial tool ask:", + expect.any(Error), + ) + // The parse error is still reported despite the failed finalization. + expect(mockHandleError).toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + } finally { + consoleErrorSpy.mockRestore() + } + }) From 9b93a6f882a4a5690aaffb763b6660eadaa308be Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:25 +0800 Subject: [PATCH 06/29] fix(write-to-file): run diff cleanup when handleError rejects --- src/core/tools/WriteToFileTool.ts | 56 ++- .../tools/__tests__/writeToFileTool.spec.ts | 383 +++++++++++++++++- 2 files changed, 424 insertions(+), 15 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ba25a9f2c0..653f3c729b 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -245,12 +245,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.diffViewProvider.editType = fileExists ? "modify" : "create" } - // Create parent directories early for new files to prevent ENOENT errors - // in subsequent operations (e.g., diffViewProvider.open, fs.readFile) - if (!fileExists) { - await createDirectoriesForFile(absolutePath) - } - if (newContent.startsWith("```")) { newContent = newContent.split("\n").slice(1).join("\n") } @@ -274,7 +268,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { isProtected: isWriteProtected, } + // Tracks whether the user approved the write, so the error path only reverts the + // diff document when the content was never approved (an approved edit is kept in + // the editor so the user can save it manually after a late failure). + let writeApproved = false + try { + // Create parent directories for new files inside the try block so filesystem + // errors (EROFS, EACCES, etc.) route through handleError with proper cleanup + // and consecutive-mistake counting, rather than escaping unhandled. + if (!fileExists) { + await createDirectoriesForFile(absolutePath) + } + task.consecutiveMistakeCount = 0 const provider = task.providerRef.deref() @@ -308,9 +314,17 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { + // The prevent-focus branch set editType/originalContent on the provider + // before asking. Clear them on denial (no diff document was opened in + // this branch, so reset() is sufficient; the non-prevent-focus denial + // branch resets through revertChanges()), so a later write re-checks + // the file system instead of reusing the stale editType. + await this.resetDiffViewAfterWrite(task) return } + writeApproved = true + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) } else { if (!task.diffViewProvider.isEditing) { @@ -344,6 +358,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } + writeApproved = true + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } @@ -357,17 +373,35 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) - await task.diffViewProvider.reset() - this.resetPartialState() + await this.resetDiffViewAfterWrite(task) task.processQueuedMessages() return } catch (error) { - await handleError("writing file", error as Error) - await task.diffViewProvider.reset() - this.resetPartialState() + // Finalize any open partial tool message so the UI spinner doesn't get stuck. + // The partial ask fired during streaming (handlePartial) or early in execute sets + // partial: true on the webview message; without this, the spinner persists even + // after the error bubble appears. + await this.finalizePartialToolAskAfterFailure(task) + // The diff cleanup runs in a finally around handleError: the production + // handleError awaits Task.say(), which rejects when the task is aborted, and a + // rejected handleError must not skip restoring the unapproved streamed content. + try { + await handleError("writing file", error as Error) + } finally { + // Before approval the diff document holds unapproved streamed content: + // restore it so a user save cannot persist it. After approval the content + // is the user's accepted edit -- keep it in the editor (dirty) so they can + // save it manually. + if (!writeApproved) { + await this.revertDiffChangesBeforeReset(task) + } + await this.resetDiffViewAfterWrite(task) + } return + } finally { + this.resetTaskPartialState(task) } } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index dddd139011..7bdd3cbdb2 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -170,6 +170,7 @@ describe("writeToFileTool", () => { update: vi.fn().mockResolvedValue(undefined), reset: vi.fn().mockResolvedValue(undefined), revertChanges: vi.fn().mockResolvedValue(undefined), + saveDirectly: vi.fn().mockResolvedValue(undefined), saveChanges: vi.fn().mockResolvedValue({ newProblemsMessage: "", userEdits: null, @@ -310,15 +311,16 @@ describe("writeToFileTool", () => { ) it.skipIf(process.platform === "win32")( - "creates parent directories when path has stabilized (partial)", + "does not create directories in handlePartial -- only execute() creates them", async () => { - // First call - path not yet stabilized + // First call - path not yet stabilized, early return await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() - // Second call with same path - path is now stabilized + // Second call with same path - path stabilized, handlePartial runs but + // must NOT call createDirectoriesForFile (directory creation belongs in execute) await executeWriteFileTool({}, { fileExists: false, isPartial: true }) - expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() }, ) @@ -415,6 +417,25 @@ describe("writeToFileTool", () => { // Should process normally without issues expect(mockCline.consecutiveMistakeCount).toBe(0) }) + + it("does not report a successful write as failed when final diff reset rejects", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCline.diffViewProvider.reset.mockRejectedValue(new Error("reset failed")) + + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Tool result message") + expect(mockCline.didEditFile).toBe(true) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error resetting write_to_file diff view:", + expect.any(Error), + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("partial block handling", () => { @@ -949,11 +970,146 @@ describe("writeToFileTool", () => { } }) + it("reports a filesystem error only once across the streaming and execute phases", async () => { + // Regression test for the double-error UX defect: a single write_to_file call to a + // read-only path failed twice -- once in handlePartial ("handling partial write_to_file") + // and once in execute() ("writing file"). handlePartial now swallows its error so only + // the authoritative execute() error is surfaced. + const erofs = () => + Object.assign(new Error("EROFS: read-only file system, mkdir '/scratch'"), { code: "EROFS" }) + mockCline.diffViewProvider.open.mockRejectedValue(erofs()) + mockedCreateDirectoriesForFile.mockRejectedValue(erofs()) + + // Streaming phase: stabilize path then fail (swallowed, no handleError) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + // Final phase: execute() reports the single authoritative error + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockHandleError).toHaveBeenCalledTimes(1) + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + }) + + it("does not reset consecutive mistake count when directory creation fails", async () => { + mockCline.consecutiveMistakeCount = 3 + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), + ) + + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.consecutiveMistakeCount).toBe(3) + }) + + it("reverts the diff document when the write fails before approval", async () => { + // Regression test for the dirty-diff leak: streaming already opened the diff view + // with unapproved content, and the write then failed before the user could approve + // it. reset() alone left the diff document dirty with the streamed content -- a + // user save in the editor would persist a write the task never completed. The + // error path must revert the document (like the approval-denied path does) before + // resetting the provider state. + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), + ) + // Record the relative order of revertChanges() and reset() (vitest mocks expose + // no invocationCallOrder). + const diffViewCallOrder: string[] = [] + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + + // Stream two deltas so the diff view is open with the unapproved content... + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + // ...then the completed block fails before approval + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + }) + + it("continues cleanup when reverting the diff document fails before approval", async () => { + // Pins the .catch arm on revertChanges() in revertDiffChangesBeforeReset(): a failed + // revert (e.g. the diff view was already closed) must only be logged so the + // remaining cleanup (diff view reset + per-task state teardown) always completes. + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValue(new Error("revert failed")) + + // Stream two deltas so the diff view opens with the unapproved content... + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + // ...then the completed block fails before approval and the revert fails too. + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error reverting write_to_file diff view changes:", + expect.any(Error), + ) + // The diff view is still reset and the per-task stream state still torn down. + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + // The reference that was registered must be the one removed. + expect(abortCleanup).toBeTypeOf("function") + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("keeps approved diff content in the editor when saving fails after approval", async () => { + // The reverse of the previous test: once the user approved the write, the diff + // content is their accepted edit. A late failure (e.g. saveChanges rejecting) + // must NOT revert it -- the document stays dirty so the user can save it manually. + mockCline.diffViewProvider.saveChanges.mockRejectedValueOnce(new Error("save failed")) + await executeWriteFileTool({}, { fileExists: false }) + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + }) + it("continues execute error cleanup when finalizing partial ask fails", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), + ) + mockCline.finalizePartialToolAsk.mockRejectedValue(new Error("finalize failed")) + await executeWriteFileTool({}, { fileExists: false }) + // The execute error path finalizes without a text match: any open partial + // tool ask is closed. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error finalizing write_to_file partial tool ask:", + expect.any(Error), + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) it("keeps partial stream failures isolated per task", async () => { mockCline.diffViewProvider.open.mockRejectedValueOnce( @@ -979,7 +1135,53 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(2) }) + it("swallows diff view reset errors during partial failure cleanup", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCline.diffViewProvider.open.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, mkdir '/scratch'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.reset.mockRejectedValue(new Error("reset failed")) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error resetting write_to_file diff view:", + expect.any(Error), + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("continues partial failure cleanup when finalizing partial ask fails", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCline.diffViewProvider.open.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, mkdir '/scratch'"), { code: "EROFS" }), + ) + mockCline.finalizePartialToolAsk.mockRejectedValue(new Error("finalize failed")) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error finalizing write_to_file partial tool ask:", + expect.any(Error), + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) it("EROFS in handlePartial does not stall agent loop -- createDirectoriesForFile is not called", async () => { // Regression test: before the fix, createDirectoriesForFile was called in handlePartial @@ -1002,8 +1204,181 @@ describe("writeToFileTool", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("EROFS in execute() routes through handleError with cleanup rather than escaping unhandled", async () => { + // Regression test: before the fix, createDirectoriesForFile in execute() sat outside + // the try block (lines 70-74), so an EROFS error escaped the catch at line 188 entirely. + // After the fix the call is inside the try block, so filesystem errors are caught and + // routed through handleError with proper diffViewProvider.reset() cleanup. + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, mkdir '/scratch'"), { code: "EROFS" }), + ) + await executeWriteFileTool({}, { fileExists: false }) + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + // The tool must not have proceeded to open or save + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + }) + + it("finalizes partial tool message on error so the UI spinner does not get stuck", async () => { + // Regression test: when a filesystem error is thrown in execute() the webview + // message created during handlePartial (or the early ask in execute) is stuck in + // partial: true state, showing an indefinite spinner alongside the error bubble. + // The catch block must call finalizePartialToolAsk() to close the spinner without + // blocking for user input. + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), + ) + + await executeWriteFileTool({}, { fileExists: false }) + + // handleError must still be called + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + // finalizePartialToolAsk must have been called (no text: the execute error + // path closes whichever partial tool ask is open) to dismiss the spinner + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) + // The write was never approved, so the diff document is reverted before reset + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + }) + + it("runs diff cleanup when handleError rejects", async () => { + // The production handleError awaits Task.say(), which rejects when the task is + // aborted. A rejected handleError must not skip the diff cleanup: the unapproved + // streamed content has to be reverted and the diff view reset, or a user save + // could persist the failed write. The handleError rejection itself propagates + // (it is not swallowed by the cleanup). As in the missing-parameter tests + // above, the revert mock awaits a deferred so the test proves the cleanup + // AWAITs revertChanges() before reset(): with the revert still pending, + // reset() must not have run yet. + mockHandleError.mockRejectedValue(new Error("handleError rejected (aborted task)")) + mockedCreateDirectoriesForFile.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), + ) + const diffViewCallOrder: string[] = [] + let resolveRevert: () => void = () => {} + const revertDeferred = new Promise((resolve) => { + resolveRevert = resolve + }) + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + await revertDeferred + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + + const executePromise = executeWriteFileTool({}, { fileExists: false }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // handleError was attempted with the write context and the cleanup has reached + // the deferred revert... + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + // ...and while the revert is still pending, reset() must not have run yet. + expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() + + resolveRevert() + await expect(executePromise).rejects.toThrow("handleError rejected (aborted task)") + + // The unapproved content is reverted before the diff view reset. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + }) + }) + + describe("prevent focus disruption experiment", () => { + /** + * Enable the PREVENT_FOCUS_DISRUPTION experiment for the current task: the experiment + * branches in execute()/handlePartial() read it from the provider state they fetch. + */ + function enablePreventFocusDisruption(): void { + mockCline.providerRef = { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }), + } + } + + it("saves through saveDirectly without diff editor interaction when the experiment is enabled", async () => { + enablePreventFocusDisruption() + + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + ) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(true) + expect(toolResult).toBe("Tool result message") + }) + + it("keeps approved diff content when saveDirectly fails after approval", async () => { + // The experiment branch stamps writeApproved before saveDirectly, so a late failure + // must NOT revert the document (the user approved the edit and can save it + // manually) but must still finalize the partial ask and reset the diff view. + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + enablePreventFocusDisruption() + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(new Error("save failed")) + + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("skips streaming diff view work when the experiment is enabled", async () => { + // With the experiment enabled the tool preview is embedded in the complete message + // built in execute(), so handlePartial must not open or update the diff view while + // streaming. + enablePreventFocusDisruption() + + // Delta 1 - stabilize path; delta 2 - path stabilized but the experiment short-circuits + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.ask).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() + }) + + it("clears the provider state when prevent-focus approval is denied", async () => { + // The prevent-focus branch stamps editType/originalContent on the provider before + // asking. On denial nothing was approved and no diff document was opened, so the + // provider state must be cleared (reset) to make a later write re-check the file + // system instead of reusing the stale editType. The non-prevent-focus denial branch + // resets through revertChanges(); this branch must not call it (no document to + // revert). + enablePreventFocusDisruption() + mockAskApproval.mockResolvedValue(false) + + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockAskApproval).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) }) }) From 646c7873926f6c9d82dc45fdc5549269375eb446 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:18:25 +0800 Subject: [PATCH 07/29] fix(write-to-file): clean partial state on missing-param and rooignore denial --- src/core/tools/WriteToFileTool.ts | 29 ++- .../tools/__tests__/writeToFileTool.spec.ts | 212 +++++++++++++++++- 2 files changed, 237 insertions(+), 4 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 653f3c729b..3d665f2187 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -213,7 +213,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "path")) - await task.diffViewProvider.reset() + // handlePartial() has no missing-parameter guard, so streaming deltas for a + // stabilized path may already have created a partial `tool` ask (partial: true) + // before execute() saw the malformed payload. Finalize it so the UI spinner + // does not stay stuck, mirroring the rooignore and execute-error cleanups. + await this.finalizePartialToolAskAfterFailure(task) + await this.revertDiffChangesBeforeReset(task) + await this.resetDiffViewAfterWrite(task) + this.resetTaskPartialState(task) return } @@ -221,7 +228,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "content")) - await task.diffViewProvider.reset() + // Same partial-ask cleanup as the missing-`path` branch above: a partial `tool` + // ask created during streaming would otherwise stay open (partial: true). + await this.finalizePartialToolAskAfterFailure(task) + await this.revertDiffChangesBeforeReset(task) + await this.resetDiffViewAfterWrite(task) + this.resetTaskPartialState(task) return } @@ -230,6 +242,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (!accessAllowed) { await task.say("rooignore_error", relPath) pushToolResult(formatResponse.rooIgnoreError(relPath)) + // handlePartial() has no rooignore guard, so streaming deltas for this denied + // path may already have created a partial `tool` ask (partial: true) and opened + // the diff view before execute() reached the access check. Denying here without + // cleanup would leave the UI spinner stuck (partial: true), the diff view open + // with the denied content still dirty in the editor, and this task's per-task + // stream state leaked. Perform the same cleanup the try/finally path does + // before returning. + await this.finalizePartialToolAskAfterFailure(task) + // The write was denied before approval: restore the document so a user save + // cannot persist the streamed content. + await this.revertDiffChangesBeforeReset(task) + await this.resetDiffViewAfterWrite(task) + this.resetTaskPartialState(task) return } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 7bdd3cbdb2..2128efd576 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -248,8 +248,13 @@ describe("writeToFileTool", () => { ...params, }, nativeArgs: { - path: (params.path ?? testFilePath) as any, - content: (params.content ?? testContent) as any, + // The missing-parameter tests inject `undefined` where + // NativeToolArgs["write_to_file"] declares `string`, so the casts are required to + // model a malformed payload. + path: (Object.prototype.hasOwnProperty.call(params, "path") ? params.path : testFilePath) as any, + content: (Object.prototype.hasOwnProperty.call(params, "content") + ? params.content + : testContent) as any, }, partial: isPartial, } @@ -274,6 +279,155 @@ describe("writeToFileTool", () => { expect(mockCline.rooIgnoreController.validateAccess).toHaveBeenCalledWith(testFilePath) expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) }) + + it("finalizes the partial ask and clears per-task state when rooignore denies access", async () => { + // handlePartial() has no rooignore guard, so streaming deltas for a denied path + // still create a partial `tool` ask (partial: true) and open the diff view before + // execute() reaches the access check. The denial must clean up all of that: + // finalize the partial ask (spinner does not stick), revert the diff document so a + // user save cannot persist the denied content, reset the diff view (reset failures + // swallowed), and clear the per-task stream state (abort listener + entries). + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + // Record the relative order of revertChanges() and reset(): vitest mocks expose + // no invocationCallOrder, so the ordering assertion uses this sequence. + const diffViewCallOrder: string[] = [] + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + throw new Error("reset failed") + }) + + // Stream two deltas so the path stabilizes: handlePartial registers the abort + // cleanup and opens the partial ask + diff view for the (soon denied) path. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + expect(abortCleanup).toBeTypeOf("function") + + // The completed block now reaches the access check, which denies the path. + await executeWriteFileTool({}, { fileExists: false, accessAllowed: false }) + + expect(mockCline.say).toHaveBeenCalledWith("rooignore_error", testFilePath) + // The denial finalizes without a text match: any open partial tool ask is closed. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) + // The denied write's streamed content must be reverted from the diff document + // BEFORE reset() clears the state revertChanges() relies on. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error resetting write_to_file diff view:", + expect.any(Error), + ) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + } finally { + consoleErrorSpy.mockRestore() + } + }) + }) + + describe("missing-parameter early-return cleanup", () => { + // handlePartial() has no missing-parameter guard: two partial streaming calls + // stabilize the path and open the partial `tool` ask + diff view. This establishes + // the "partial ask is open" precondition for the missing-parameter branches below. + async function streamPartialAsk() { + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(mockCline.ask).toHaveBeenCalledTimes(1) + } + + it("finalizes the partial ask when content is missing after partial streaming", async () => { + // Streaming deltas create a partial `tool` ask (partial: true), then the completed + // payload is missing `content`. The missing-parameter branch must finalize the ask + // (the spinner must not stick) and still perform the same diff-view revert / reset + // and per-task-state cleanup as the other early-return paths. + // Record the relative order of revertChanges() and reset(): vitest mocks expose + // no invocationCallOrder, so the ordering assertion uses this sequence. The + // revert mock awaits a deferred so the test proves the branch AWAITs + // revertChanges() before reset(): with the revert still pending, reset() must + // not have run yet. + const diffViewCallOrder: string[] = [] + let resolveRevert: () => void = () => {} + const revertDeferred = new Promise((resolve) => { + resolveRevert = resolve + }) + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + await revertDeferred + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + await streamPartialAsk() + + // The missing-parameter branch awaits revertChanges() before reset(): with the + // deferred revert still pending, reset() must not have run yet. + const executePromise = executeWriteFileTool({ content: undefined }, { fileExists: false }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() + + resolveRevert() + await executePromise + + expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "content") + // The missing-parameter path finalizes without a text match: any open partial ask is closed. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) + // The streamed content of the failed write must be reverted from the diff + // document before reset() clears the state revertChanges() relies on. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("finalizes the partial ask when path is missing after partial streaming", async () => { + // Same scenario with the `path` field missing: the missing-`path` branch must run + // the identical partial-ask + diff-view + per-task-state cleanup. As in the + // content-missing test above, the revert mock awaits a deferred so the test + // proves the branch AWAITs revertChanges() before reset(): with the revert + // still pending, reset() must not have run yet. + const diffViewCallOrder: string[] = [] + let resolveRevert: () => void = () => {} + const revertDeferred = new Promise((resolve) => { + resolveRevert = resolve + }) + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + await revertDeferred + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + await streamPartialAsk() + + // The missing-parameter branch awaits revertChanges() before reset(): with the + // deferred revert still pending, reset() must not have run yet. + const executePromise = executeWriteFileTool({ path: undefined }, { fileExists: false }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() + + resolveRevert() + await executePromise + + expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "path") + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) + // The diff document must be reverted before reset() clears the state + // revertChanges() relies on. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(mockHandleError).not.toHaveBeenCalled() + }) }) describe("file existence detection", () => { @@ -785,7 +939,61 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() }) + it("uses safe reset and clears partial state when path is missing", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + mockCline.diffViewProvider.reset.mockRejectedValue(new Error("reset failed")) + + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({ path: "" }) + + expect(mockCline.recordToolError).toHaveBeenCalledWith("write_to_file") + expect(mockPushToolResult).toHaveBeenCalledWith("Missing param error") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error resetting write_to_file diff view:", + expect.any(Error), + ) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("uses safe reset and clears partial state when content is missing", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + mockCline.diffViewProvider.reset.mockRejectedValue(new Error("reset failed")) + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({ content: undefined }) + + expect(mockCline.recordToolError).toHaveBeenCalledWith("write_to_file") + expect(mockPushToolResult).toHaveBeenCalledWith("Missing param error") + expect(mockHandleError).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error resetting write_to_file diff view:", + expect.any(Error), + ) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + } finally { + consoleErrorSpy.mockRestore() + } + }) it("swallows partial streaming errors instead of surfacing a duplicate error bubble", async () => { // The same filesystem operation is retried in execute() once the block completes, From 418a49461877abb404a81880a94990bf7372bcb6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:48:59 +0800 Subject: [PATCH 08/29] fix(tools): do not report a completed teardown when the diff revert fails onParameterParseFailure() tore down the per-task stream state first, then reverted the diff document with the failure logged and swallowed, then reset the diff provider. A failed revert therefore looked like a completed teardown even though the editor can still hold the unapproved partial content - and a user save of that editor lands a write the task never approved, with nothing in the UI saying so. The revert now runs while the recovery state still exists, revertDiffChangesBeforeReset returns whether it succeeded, and a failed revert is surfaced with task.say("error", ...) naming the hazard. The remaining cleanup (reset + per-task teardown) still runs, so the task is never left half-torn-down. Test: the seeded per-task state is observed from inside the failing revertChanges double, so the ordering is pinned (state present during rollback, gone afterwards), and the error say is asserted. Without the fix the test fails (1 failed / 5 passed); with it 6 pass. The streaming-failure cleanup keeps its existing log-only behaviour: that path's error is reported by the authoritative non-partial block in execute(), as its comment states, so adding a second message there would double-report the same failure. --- src/core/tools/WriteToFileTool.ts | 35 ++++++++++++++----- ...teToFileTool-partial-state-cleanup.spec.ts | 35 +++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 3d665f2187..5286b45a31 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -144,13 +144,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * streamed content; a user save would then persist a write the task never completed * (denied or failed before approval). Must run BEFORE resetDiffViewAfterWrite(), * since reset() clears the state revertChanges() relies on. No-op when no diff view - * is open. Failures are logged and swallowed so the remaining cleanup (reset, - * per-task state teardown) always continues. + * is open. Failures are logged and reported through the return value: the caller + * must not treat the teardown as complete when this returns false, because the + * document may still hold the unapproved content, but the remaining cleanup (reset, + * per-task state teardown) still runs so the task is not left half-torn-down. */ - private async revertDiffChangesBeforeReset(task: Task): Promise { - await task.diffViewProvider.revertChanges().catch((revertError) => { + private async revertDiffChangesBeforeReset(task: Task): Promise { + try { + await task.diffViewProvider.revertChanges() + return true + } catch (revertError) { console.error("Error reverting write_to_file diff view changes:", revertError) - }) + return false + } } private async finalizePartialToolAskAfterFailure(task: Task, text?: string): Promise { @@ -179,15 +185,26 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (!state) { return false } - this.resetTaskPartialState(task) // Streaming may have opened the diff view with unapproved partial content. // execute() never runs on this path, so its error cleanup (revert + reset) // never fires: restore the document here so a user save cannot persist // content the write never completed (the same invariant the denial and - // streaming-failure paths maintain). Both helpers no-op when no view is - // open. - await this.revertDiffChangesBeforeReset(task) + // streaming-failure paths maintain). Both helpers no-op when no view is open. + // The revert runs BEFORE the per-task state is torn down: when it fails, the + // document can still hold that unapproved content, and the recovery state has + // to exist while the outcome is decided and reported. + const reverted = await this.revertDiffChangesBeforeReset(task) + this.resetTaskPartialState(task) await this.resetDiffViewAfterWrite(task) + if (!reverted) { + // Do not report a completed teardown: the editor may still show content this + // task never approved, and saving it would land a write the user never + // authorized. The user has to be able to tell that from the UI. + await task.say( + "error", + "write_to_file: the diff editor could not be restored after the failed tool call, so it may still show unapproved content. Do not save that editor.", + ) + } if (!state.streamError) { return false } diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts index 4506742c26..1154664e48 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -19,6 +19,7 @@ interface CleanupTask { revertChanges: MockedFunction<() => Promise> } finalizePartialToolAsk: MockedFunction<() => Promise> + say: MockedFunction<(...args: unknown[]) => Promise> } function buildTask(taskId: string, instanceId: string): Task { @@ -32,6 +33,7 @@ function buildTask(taskId: string, instanceId: string): Task { revertChanges: vi.fn().mockResolvedValue(undefined), }, finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), + say: vi.fn().mockResolvedValue(undefined), } return task as unknown as Task } @@ -87,6 +89,39 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { expect(errorSpy).toHaveBeenCalledWith("Error reverting write_to_file diff view changes:", expect.any(Error)) }) + it("reverts while the recovery state exists and reports the hazard when the revert fails", async () => { + const task = buildTask("revert-fails-teardown", "inst-6") + const t = task as unknown as CleanupTask + let stateSizeDuringRevert = -1 + t.diffViewProvider.revertChanges = vi.fn(async () => { + // The recovery state must still be present while the rollback runs. + stateSizeDuringRevert = writeToFileTool["taskPartialStreamState"].size + throw new Error("revert failed") + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + // Seed the per-task stream state so this teardown boundary is taken at all. + writeToFileTool["getTaskPartialStreamState"](task) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + + const handled = await writeToFileTool["onParameterParseFailure"]( + task, + // Only handleError is reached when no streaming error was recorded; the + // structural double is the existing pattern in this file. + { handleError: vi.fn().mockResolvedValue(undefined) } as unknown as Parameters[1], + new Error("parameter parse failed"), + ) + + // A failed rollback is not a completed teardown: the user is told the editor may + // still hold content the task never approved. + expect(t.say).toHaveBeenCalledWith("error", expect.stringContaining("unapproved")) + expect(stateSizeDuringRevert).toBe(1) + // The teardown still finished. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(t.diffViewProvider.reset).toHaveBeenCalled() + expect(handled).toBe(false) + errorSpy.mockRestore() + }) + it("logs and continues when finalizing the open partial ask fails", async () => { const task = buildTask("finalize-fails", "inst-5") const t = task as unknown as CleanupTask From 9ac9068ba853590939223bbbbd07ef77f7d246d0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 07:05:13 +0800 Subject: [PATCH 09/29] test(tools): cover rollback failure with a retained stream error, and keep a failing report non-fatal Same follow-up as #1931's open review thread, kept identical across the chain: - rollback fails while a streaming filesystem error is retained: both reports happen - the hazard say and handleError("writing file", streamError) - and the handler returns true. - the hazard report itself rejects: the report was awaited inline, so a rejecting task.say would have thrown out of onParameterParseFailure and swallowed the user's only actionable error. It now goes through reportRevertFailure(), which logs a failing say and continues - matching finalizePartialToolAskAfterFailure. Verified by stashing the source change: 1 failed / 7 passed without it, 8 passed with it. eslint clean with --prune-suppressions (no suppression change); package tsc reports nothing in the touched files. --- src/core/tools/WriteToFileTool.ts | 24 +++++++-- ...teToFileTool-partial-state-cleanup.spec.ts | 49 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 5286b45a31..1ba38b4870 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -165,6 +165,25 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { }) } + /** + * Surface a failed rollback to the user. The hazard has to be visible in the chat, + * not only in the console: the editor may still hold content the task never + * approved and a save of it would land an unauthorized write. A failing say must + * not abort the teardown - a retained streaming error still has to reach the user + * through handleError - so its failure is logged only, matching the other cleanup + * helpers in this file. + */ + private async reportRevertFailure(task: Task): Promise { + await task + .say( + "error", + "write_to_file: the diff editor could not be restored after the failed tool call, so it may still show unapproved content. Do not save that editor.", + ) + .catch((sayError) => { + console.error("Error reporting write_to_file rollback failure:", sayError) + }) + } + /** * Teardown boundary for the handle() parse-failure path, where execute() never * runs and therefore its finally (resetTaskPartialState) never runs either. @@ -200,10 +219,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // Do not report a completed teardown: the editor may still show content this // task never approved, and saving it would land a write the user never // authorized. The user has to be able to tell that from the UI. - await task.say( - "error", - "write_to_file: the diff editor could not be restored after the failed tool call, so it may still show unapproved content. Do not save that editor.", - ) + await this.reportRevertFailure(task) } if (!state.streamError) { return false diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts index 1154664e48..b77fe9a089 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -122,6 +122,55 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { errorSpy.mockRestore() }) + it("reports the rollback hazard AND the retained streaming error, and returns true", async () => { + const task = buildTask("revert-fails-with-stream-error", "inst-7") + const t = task as unknown as CleanupTask + t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const state = writeToFileTool["getTaskPartialStreamState"](task) + const streamError = new Error("filesystem failure while streaming") + state.streamFailed = true + state.streamError = streamError + const handleError = vi.fn().mockResolvedValue(undefined) + + const handled = await writeToFileTool["onParameterParseFailure"]( + task, + { handleError } as unknown as Parameters[1], + new Error("parameter parse failed"), + ) + + // Both reports happen: the rollback hazard and the error the user can act on. + expect(t.say).toHaveBeenCalledWith("error", expect.stringContaining("unapproved")) + expect(handleError).toHaveBeenCalledWith("writing file", streamError) + expect(handled).toBe(true) + errorSpy.mockRestore() + }) + + it("still reports the streaming error when the rollback warning itself fails", async () => { + const task = buildTask("rollback-warning-fails", "inst-8") + const t = task as unknown as CleanupTask + t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + t.say = vi.fn().mockRejectedValue(new Error("say failed")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const state = writeToFileTool["getTaskPartialStreamState"](task) + const streamError = new Error("filesystem failure while streaming") + state.streamFailed = true + state.streamError = streamError + const handleError = vi.fn().mockResolvedValue(undefined) + + const handled = await writeToFileTool["onParameterParseFailure"]( + task, + { handleError } as unknown as Parameters[1], + new Error("parameter parse failed"), + ) + + // A failing report must not abort the teardown: the streaming error still lands. + expect(errorSpy).toHaveBeenCalledWith("Error reporting write_to_file rollback failure:", expect.any(Error)) + expect(handleError).toHaveBeenCalledWith("writing file", streamError) + expect(handled).toBe(true) + errorSpy.mockRestore() + }) + it("logs and continues when finalizing the open partial ask fails", async () => { const task = buildTask("finalize-fails", "inst-5") const t = task as unknown as CleanupTask From c59a283f43ad90731736a561b7499d1913597d7d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 07:15:25 +0800 Subject: [PATCH 10/29] fix(tools): report the rollback hazard from every write_to_file cleanup path Follow-up to the same CodeRabbit Persistence Integrity class on #1931: this unit adds extra cleanup paths (missing-parameter early returns and the execute() catch) that call revertDiffChangesBeforeReset() and discard the result. A failed restore therefore left unapproved content in the editor with no user-visible signal - the error the user sees on those paths is the write failure, not the failed rollback. - revert -> reset -> report is now one helper, cleanupFailedPartialStream(), used by the streaming cleanup and the two early-return cleanups. - The execute() finally keeps its approved/unapproved split: after approval the accepted edit stays in the editor; before approval a failed restore is now reported. Test: cleanupFailedPartialStream driven directly with a rejecting revertChanges double - hazard say and provider reset both asserted. Stashing the source change: 1 failed / 8 passed; with it 9 passed. eslint clean with --prune-suppressions (no suppression change); package tsc reports nothing in the touched files. --- src/core/tools/WriteToFileTool.ts | 40 ++++++++++++++----- ...teToFileTool-partial-state-cleanup.spec.ts | 14 +++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 1ba38b4870..35c4f57aef 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -184,6 +184,21 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { }) } + /** + * Cleanup for a failed partial stream: restore the diff document, close the view, + * and - when the restore itself failed - tell the user the editor may still hold + * unapproved content, the same way the parse-failure teardown does. Without the + * report, a failed rollback here is invisible: the stream error that triggered the + * cleanup is a different failure and is reported elsewhere. + */ + private async cleanupFailedPartialStream(task: Task): Promise { + const reverted = await this.revertDiffChangesBeforeReset(task) + await this.resetDiffViewAfterWrite(task) + if (!reverted) { + await this.reportRevertFailure(task) + } + } + /** * Teardown boundary for the handle() parse-failure path, where execute() never * runs and therefore its finally (resetTaskPartialState) never runs either. @@ -251,8 +266,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // before execute() saw the malformed payload. Finalize it so the UI spinner // does not stay stuck, mirroring the rooignore and execute-error cleanups. await this.finalizePartialToolAskAfterFailure(task) - await this.revertDiffChangesBeforeReset(task) - await this.resetDiffViewAfterWrite(task) + await this.cleanupFailedPartialStream(task) this.resetTaskPartialState(task) return } @@ -264,8 +278,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // Same partial-ask cleanup as the missing-`path` branch above: a partial `tool` // ask created during streaming would otherwise stay open (partial: true). await this.finalizePartialToolAskAfterFailure(task) - await this.revertDiffChangesBeforeReset(task) - await this.resetDiffViewAfterWrite(task) + await this.cleanupFailedPartialStream(task) this.resetTaskPartialState(task) return } @@ -285,8 +298,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await this.finalizePartialToolAskAfterFailure(task) // The write was denied before approval: restore the document so a user save // cannot persist the streamed content. - await this.revertDiffChangesBeforeReset(task) - await this.resetDiffViewAfterWrite(task) + await this.cleanupFailedPartialStream(task) this.resetTaskPartialState(task) return } @@ -452,10 +464,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // restore it so a user save cannot persist it. After approval the content // is the user's accepted edit -- keep it in the editor (dirty) so they can // save it manually. + let reverted = true if (!writeApproved) { - await this.revertDiffChangesBeforeReset(task) + reverted = await this.revertDiffChangesBeforeReset(task) } await this.resetDiffViewAfterWrite(task) + if (!reverted) { + // The restore failed, so the editor still holds content this task never approved. + // The error the user sees on this path is the write failure, not this one. + await this.reportRevertFailure(task) + } } return } finally { @@ -549,9 +567,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { partialStreamState.streamError = error instanceof Error ? error : new Error(String(error)) await this.finalizePartialToolAskAfterFailure(task, partialMessage) // The write was never approved: restore the document so a user save cannot - // persist the failed streamed content (reset() alone leaves it dirty). - await this.revertDiffChangesBeforeReset(task) - await this.resetDiffViewAfterWrite(task) + // persist the failed streamed content (reset() alone leaves it dirty), and + // surface the hazard if that restore itself failed. The stream error is + // reported by the authoritative non-partial path in execute(); the rollback + // hazard is a different failure and nothing else in this path says so. + await this.cleanupFailedPartialStream(task) } } } diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts index b77fe9a089..2cc0d4d63c 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -171,6 +171,20 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { errorSpy.mockRestore() }) + it("surfaces the rollback hazard from the failed-stream cleanup as well", async () => { + const task = buildTask("failed-stream-cleanup", "inst-9") + const t = task as unknown as CleanupTask + t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await writeToFileTool["cleanupFailedPartialStream"](task) + + // Same contract as the parse-failure teardown: a failed restore is reported. + expect(t.say).toHaveBeenCalledWith("error", expect.stringContaining("unapproved")) + expect(t.diffViewProvider.reset).toHaveBeenCalled() + errorSpy.mockRestore() + }) + it("logs and continues when finalizing the open partial ask fails", async () => { const task = buildTask("finalize-fails", "inst-5") const t = task as unknown as CleanupTask From 37094030902aadedb8dff582bffe547717192391 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 07:36:29 +0800 Subject: [PATCH 11/29] test(tools): drop the as-any casts from the write_to_file harness The harness built nativeArgs with two `as any` casts to model a deliberately malformed payload (undefined path/content where NativeToolArgs declares string). The object is now a plain partial record with one assertion through unknown onto ToolUse<"write_to_file">["nativeArgs"], and the comment says why the assertion is needed. Real suppression decrease, staged as required: core/tools/__tests__/writeToFileTool.spec.ts @typescript-eslint/no-explicit-any 5 -> 3 (edited in place, so the file diff is one line). Spec behaviour unchanged: 59 passed / 5 skipped; eslint clean with --max-warnings=0; package tsc reports nothing in the file. --- src/core/tools/__tests__/writeToFileTool.spec.ts | 12 +++++------- src/eslint-suppressions.json | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 2128efd576..552a12d813 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -249,13 +249,11 @@ describe("writeToFileTool", () => { }, nativeArgs: { // The missing-parameter tests inject `undefined` where - // NativeToolArgs["write_to_file"] declares `string`, so the casts are required to - // model a malformed payload. - path: (Object.prototype.hasOwnProperty.call(params, "path") ? params.path : testFilePath) as any, - content: (Object.prototype.hasOwnProperty.call(params, "content") - ? params.content - : testContent) as any, - }, + // NativeToolArgs["write_to_file"] declares `string`, so one assertion through + // unknown is what models the malformed payload - no `any` needed. + path: Object.prototype.hasOwnProperty.call(params, "path") ? params.path : testFilePath, + content: Object.prototype.hasOwnProperty.call(params, "content") ? params.content : testContent, + } as unknown as ToolUse<"write_to_file">["nativeArgs"], partial: isPartial, } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 583485c628..c5f053f677 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1011,7 +1011,7 @@ }, "core/tools/__tests__/writeToFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 3 } }, "core/tools/helpers/toolResultFormatting.ts": { From b14605602af7ec7843ab991c688cd93d7f24713c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Wed, 7 Oct 2026 06:49:33 +0000 Subject: [PATCH 12/29] chore: trigger a fresh review pass at this head From 0a79bcb051409bd2e8bc7dfadfd6e2a8073289f4 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 18:35:45 +0800 Subject: [PATCH 13/29] fix(write-to-file): tear down stream state for a finalized call that never parses A streamed write_to_file whose JSON never parses is completed by Task with nativeArgs undefined. presentAssistantMessage then emits the error tool_result and breaks at its missing-nativeArgs guard, so handle() is never dispatched: neither execute()'s finally nor onParameterParseFailure() runs. The per-task stream entry, its TaskAborted listener and a diff view that streaming may have opened with unapproved partial content therefore survive into the next API request -- a stale path makes the next write's first delta look stabilized, and a retained streamFailed flag suppresses that write's preview. WriteToFileTool.teardownAbandonedStream() releases exactly that state (revert the streamed document, drop the entry + listener, reset the view) and stays silent, since the guard has already pushed the tool_result the provider waits for. The presenter calls it for write_to_file blocks only. Tests: presenter wiring spec (guard tears down instead of dispatching), tool-level teardown (entry gone, listener deregistered, revert before reset, nothing reported) and a no-state no-op. The two missing-parameter tests now also assert the per-task state is released, which they previously did not. Pins verified: removing the guard call, or the state reset in the missing-content branch, fails the matching test. Local: assistant-message + writeToFileTool + core/task 846 passed / 5 skipped; tsc 0; eslint clean on all four files. --- ...sistantMessage-missing-native-args.spec.ts | 102 ++++++++++++++++++ .../presentAssistantMessage.ts | 8 ++ src/core/tools/WriteToFileTool.ts | 23 ++++ .../tools/__tests__/writeToFileTool.spec.ts | 71 ++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts new file mode 100644 index 0000000000..a7d9e90aa5 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts @@ -0,0 +1,102 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { presentAssistantMessage } from "../presentAssistantMessage" +import { isValidToolName } from "../../tools/validateToolUse" + +const mockTeardown = vi.hoisted(() => vi.fn()) +const mockWriteHandle = vi.hoisted(() => vi.fn()) + +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn(() => true), +})) +vi.mock("../../tools/WriteToFileTool", () => ({ + writeToFileTool: { handle: mockWriteHandle, teardownAbandonedStream: mockTeardown }, +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +type ToolResultBlock = { type: string; tool_use_id: string; content: string; is_error: boolean } + +describe("presentAssistantMessage - finalized block without nativeArgs", () => { + // The presenter reads only a subset of Task. A Record keeps the double honest without + // an `any`; the single cast at the call site is the documented boundary. + let mockTask: Record + let userMessageContent: ToolResultBlock[] + + beforeEach(() => { + mockTeardown.mockReset() + mockWriteHandle.mockReset() + userMessageContent = [] + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [ + { + type: "tool_use", + name: "write_to_file", + params: {}, + partial: false, + // Streaming JSON never parsed: Task completes the block with no nativeArgs. + id: "toolu_1", + nativeArgs: undefined, + }, + ], + userMessageContent, + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + getTaskMode: vi.fn().mockResolvedValue("code"), + api: { getModel: () => ({ id: "test-model", info: {} }) }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { check: vi.fn().mockReturnValue({ allowExecution: true }) }, + providerRef: { + deref: () => ({ getState: vi.fn().mockResolvedValue({ mode: "code", customModes: [] }) }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + pushToolResultToUserContent: vi.fn((toolResult: ToolResultBlock) => { + userMessageContent.push(toolResult) + return true + }), + } + }) + + it("tears the write_to_file stream state down instead of dispatching the tool", async () => { + await presentAssistantMessage(mockTask as unknown as Parameters[0]) + + // The malformed call must not be executed, and exactly one error tool_result is + // emitted for the provider. + expect(isValidToolName).toHaveBeenCalled() + expect(mockWriteHandle).not.toHaveBeenCalled() + expect(mockTask.pushToolResultToUserContent).toHaveBeenCalledTimes(1) + expect(userMessageContent).toEqual([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "toolu_1", + is_error: true, + content: expect.stringContaining("missing nativeArgs"), + }), + ]) + // The guard bypasses handle(), so the per-task stream state has to be released here: + // otherwise the entry, its TaskAborted listener and any streamed diff view leak into + // the next API request of the same task. + expect(mockTeardown).toHaveBeenCalledTimes(1) + expect(mockTeardown).toHaveBeenCalledWith(mockTask) + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index b11538a2c6..2a0ba88990 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -552,6 +552,14 @@ export async function presentAssistantMessage(cline: Task) { is_error: true, }) + // A write_to_file call that streamed partial deltas leaves per-task stream state + // (and possibly an open diff view) behind, and this guard bypasses handle(), so + // the teardown that execute()/onParameterParseFailure would have run never does. + // Release it here instead of leaking it into the next API request. + if (block.name === "write_to_file") { + await writeToFileTool.teardownAbandonedStream(cline) + } + break } } diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 35c4f57aef..4e030b6a44 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -244,6 +244,29 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return true } + /** + * Teardown for the presenter's missing-nativeArgs guard. A finalized write_to_file + * block whose streamed JSON never parsed never reaches handle(): the presenter emits + * the tool_result and breaks, so neither execute()'s finally nor + * onParameterParseFailure() runs. Without this the per-task stream state (map entry, + * TaskAborted listener, and a diff view that streaming may have opened with unapproved + * partial content) outlives the call and leaks into the next API request: a stale path + * makes the next write's first delta look stabilized, and a retained streamFailed flag + * suppresses that write's preview. + * + * Deliberately silent - the guard has already pushed the tool_result the provider waits + * for, so reporting here would double-report the same malformed call. + */ + async teardownAbandonedStream(task: Task): Promise { + if (!this.taskPartialStreamState.has(this.getPartialStreamFailureKey(task))) { + return + } + + await this.revertDiffChangesBeforeReset(task) + this.resetTaskPartialState(task) + await this.resetDiffViewAfterWrite(task) + } + override resetPartialState(): void { super.resetPartialState() for (const state of this.taskPartialStreamState.values()) { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 552a12d813..51d63cb876 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -366,6 +366,13 @@ describe("writeToFileTool", () => { mockCline.diffViewProvider.reset.mockImplementation(async () => { diffViewCallOrder.push("reset") }) + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) await streamPartialAsk() // The missing-parameter branch awaits revertChanges() before reset(): with the @@ -386,6 +393,10 @@ describe("writeToFileTool", () => { // document before reset() clears the state revertChanges() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() + // The per-task stream state must be gone, not just the diff view: the entry and its + // TaskAborted listener would otherwise live for the rest of the task. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) }) it("finalizes the partial ask when path is missing after partial streaming", async () => { @@ -406,6 +417,13 @@ describe("writeToFileTool", () => { mockCline.diffViewProvider.reset.mockImplementation(async () => { diffViewCallOrder.push("reset") }) + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) await streamPartialAsk() // The missing-parameter branch awaits revertChanges() before reset(): with the @@ -425,6 +443,59 @@ describe("writeToFileTool", () => { // revertChanges() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() + // The per-task stream state must be gone, not just the diff view: the entry and its + // TaskAborted listener would otherwise live for the rest of the task. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + }) + }) + + describe("abandoned-stream teardown (presenter missing-nativeArgs guard)", () => { + it("releases the per-task stream state when the finalized block never reaches handle()", async () => { + // Streaming JSON that never parses: Task marks the block complete with nativeArgs + // undefined, presentAssistantMessage pushes the error tool_result and breaks, so + // neither execute()'s finally nor onParameterParseFailure() runs. The presenter + // routes the abandoned block here instead; without it the entry, the TaskAborted + // listener and the streamed diff view survive into the next API request. + let abortCleanup: (() => void) | undefined + mockCline.once.mockImplementation((event: RooCodeEventName, listener: () => void) => { + if (event === RooCodeEventName.TaskAborted) { + abortCleanup = listener + } + return mockCline + }) + const diffViewCallOrder: string[] = [] + mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewCallOrder.push("revert") + }) + mockCline.diffViewProvider.reset.mockImplementation(async () => { + diffViewCallOrder.push("reset") + }) + + // Two deltas stabilize the path: the entry, the listener, the partial ask and the + // diff view all exist before the block is finalized. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + expect(abortCleanup).toBeTypeOf("function") + + await writeToFileTool.teardownAbandonedStream(mockCline) + + // The streamed content is reverted before the view is reset, and the state is gone. + expect(diffViewCallOrder).toEqual(["revert", "reset"]) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) + // Silent by design: the guard has already emitted the tool_result, so reporting + // here would double-report the same malformed call. + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockCline.sayAndCreateMissingParamError).not.toHaveBeenCalled() + }) + + it("does nothing when the task has no stream state to release", async () => { + await writeToFileTool.teardownAbandonedStream(mockCline) + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() + expect(mockCline.off).not.toHaveBeenCalled() }) }) From fdf3d68218aaa36455b98a69487ac28c88ff25ca Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 18:57:30 +0800 Subject: [PATCH 14/29] fix(write-to-file): report the rollback hazard from the abandoned-stream teardown teardownAbandonedStream ignored revertDiffChangesBeforeReset's false result: when the streamed diff document could not be restored, the provider state was reset anyway and the editor could keep holding content the task never approved, invisibly - the only signal was a console line. The other two teardowns (cleanupFailedPartialStream and the parse-failure path) already surface this through reportRevertFailure; this path now does too, after the state and view are released so the teardown still completes. The malformed call itself stays unreported: the presenter's missing-nativeArgs guard has already pushed the tool_result, so reporting the call again would double-report it. Only the rollback failure is added. Test: 'reports the rollback hazard when the streamed content cannot be reverted' (state released, view reset, say("error", ... "could not be restored")); the success-path test now also asserts that no rollback error is reported. Pin: removing the reportRevertFailure call fails the new test. Local: writeToFileTool.spec + assistant-message + core/task = 847 passed / 5 skipped; tsc 0; eslint 0 err / 0 warn on both touched files. --- src/core/tools/WriteToFileTool.ts | 14 +++++++---- .../tools/__tests__/writeToFileTool.spec.ts | 23 +++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 4e030b6a44..d02456e6de 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -254,17 +254,23 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * makes the next write's first delta look stabilized, and a retained streamFailed flag * suppresses that write's preview. * - * Deliberately silent - the guard has already pushed the tool_result the provider waits - * for, so reporting here would double-report the same malformed call. + * Silent for the malformed call itself: the guard has already pushed the tool_result + * the provider waits for, so reporting the call again would double-report it. A FAILED + * ROLLBACK is the exception - the editor may still hold content this task never + * approved, and that hazard has to be visible in the chat exactly as the parse-failure + * and failed-stream teardowns report it. */ async teardownAbandonedStream(task: Task): Promise { if (!this.taskPartialStreamState.has(this.getPartialStreamFailureKey(task))) { return } - await this.revertDiffChangesBeforeReset(task) + const reverted = await this.revertDiffChangesBeforeReset(task) this.resetTaskPartialState(task) await this.resetDiffViewAfterWrite(task) + if (!reverted) { + await this.reportRevertFailure(task) + } } override resetPartialState(): void { @@ -600,4 +606,4 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } } -export const writeToFileTool = new WriteToFileTool() +export const writeToFileTool = new WriteToFileTool() \ No newline at end of file diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 51d63cb876..f836821dac 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -485,10 +485,29 @@ describe("writeToFileTool", () => { expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) - // Silent by design: the guard has already emitted the tool_result, so reporting - // here would double-report the same malformed call. + // The malformed call itself is not re-reported (the guard already emitted the + // tool_result), and a successful rollback has no hazard to surface. expect(mockHandleError).not.toHaveBeenCalled() expect(mockCline.sayAndCreateMissingParamError).not.toHaveBeenCalled() + expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.stringContaining("could not be restored")) + }) + + it("reports the rollback hazard when the streamed content cannot be reverted", async () => { + // Same contract as cleanupFailedPartialStream: the state is still released, but the + // user has to see that the editor may still hold unapproved content. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + mockCline.diffViewProvider.revertChanges.mockRejectedValue(new Error("revert failed")) + + await writeToFileTool.teardownAbandonedStream(mockCline) + + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("could not be restored"), + ) }) it("does nothing when the task has no stream state to release", async () => { From 711aab1556816c88867110771d679ef150eee51c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 19:10:36 +0800 Subject: [PATCH 15/29] fix(write-to-file): never persist an abandoned stream's unapproved buffer teardownAbandonedStream released the diff view through revertChanges(). For a new-file edit that branch saves the dirty buffer before deleting the file, so an abandoned partial stream wrote model output the task never approved to disk - and left it there if the delete then failed. The presenter guard stops the block from ever reaching execute(), so nothing else undoes it. DiffViewProvider.discardUnapprovedStream() is the dedicated rollback: it closes the diff views, blanks the buffer, saves only that emptied buffer, then removes the placeholder file and the directories this edit created. WriteToFileTool.releaseAbandonedDiffView() picks the path by editType - modify edits restore the pre-existing content (safe and correct), new-file edits discard. A failure on either path still reports the hazard via reportRevertFailure, so the teardown cannot silently half-complete. Tests: DiffViewProvider 'discardUnapprovedStream method' (blank -> save -> close -> unlink order, and a no-op when no view is open); the abandoned-stream teardown test now asserts discard-then-reset and that revertChanges() is NOT called for a new file, plus a modify case that must use revertChanges. Pins: routing everything through revertChanges() fails 2 tests; removing the blanking fails the DiffViewProvider order test. Local: integrations/editor + writeToFileTool.spec + assistant-message + core/task = 927 passed / 5 skipped; the only remaining failure (DiffViewProvider.spec saveChanges default delay) reproduces without these changes. tsc 0; eslint 0 err / 0 warn on all four files. --- src/core/tools/WriteToFileTool.ts | 24 +++++++- .../tools/__tests__/writeToFileTool.spec.ts | 28 +++++++++- src/integrations/editor/DiffViewProvider.ts | 42 ++++++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 56 +++++++++++++++++++ 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index d02456e6de..59e78679be 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -260,15 +260,35 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * approved, and that hazard has to be visible in the chat exactly as the parse-failure * and failed-stream teardowns report it. */ + + /** + * Release the diff view of an abandoned stream. A modify edit restores the original + * content, which is safe. A new-file edit must NOT go through revertChanges(): its + * new-file branch saves the dirty buffer - unapproved partial model output - before + * deleting the file, so it discards the buffer without persisting it instead. + */ + private async releaseAbandonedDiffView(task: Task): Promise { + try { + if (task.diffViewProvider.editType === "modify") { + await task.diffViewProvider.revertChanges() + } else { + await task.diffViewProvider.discardUnapprovedStream() + } + return true + } catch (releaseError) { + console.error("Error releasing the abandoned write_to_file diff view:", releaseError) + return false + } + } async teardownAbandonedStream(task: Task): Promise { if (!this.taskPartialStreamState.has(this.getPartialStreamFailureKey(task))) { return } - const reverted = await this.revertDiffChangesBeforeReset(task) + const released = await this.releaseAbandonedDiffView(task) this.resetTaskPartialState(task) await this.resetDiffViewAfterWrite(task) - if (!reverted) { + if (!released) { await this.reportRevertFailure(task) } } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index f836821dac..631b6ea7c4 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -465,6 +465,9 @@ describe("writeToFileTool", () => { return mockCline }) const diffViewCallOrder: string[] = [] + mockCline.diffViewProvider.discardUnapprovedStream = vi.fn(async () => { + diffViewCallOrder.push("discard") + }) mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { diffViewCallOrder.push("revert") }) @@ -482,7 +485,11 @@ describe("writeToFileTool", () => { await writeToFileTool.teardownAbandonedStream(mockCline) // The streamed content is reverted before the view is reset, and the state is gone. - expect(diffViewCallOrder).toEqual(["revert", "reset"]) + // A new-file stream was never approved: the buffer must be discarded rather than + // reverted, because revertChanges() would SAVE the partial content before deleting + // the file. The view is still reset and the state released. + expect(diffViewCallOrder).toEqual(["discard", "reset"]) + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortCleanup) // The malformed call itself is not re-reported (the guard already emitted the @@ -498,7 +505,9 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: false, isPartial: true }) await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) - mockCline.diffViewProvider.revertChanges.mockRejectedValue(new Error("revert failed")) + mockCline.diffViewProvider.discardUnapprovedStream = vi.fn(async () => { + throw new Error("discard failed") + }) await writeToFileTool.teardownAbandonedStream(mockCline) @@ -510,6 +519,21 @@ describe("writeToFileTool", () => { ) }) + it("restores the original content for a modify edit instead of discarding it", async () => { + // A modify edit has pre-existing content on disk, so restoring it is both safe + // and correct; only the new-file case must avoid the save. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + mockCline.diffViewProvider.editType = "modify" + mockCline.diffViewProvider.discardUnapprovedStream = vi.fn().mockResolvedValue(undefined) + + await writeToFileTool.teardownAbandonedStream(mockCline) + + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + it("does nothing when the task has no stream state to release", async () => { await writeToFileTool.teardownAbandonedStream(mockCline) expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..df4552bd9d 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -513,6 +513,48 @@ export class DiffViewProvider { return JSON.stringify(result) } + /** + * Release a diff view whose content was never approved (an abandoned partial + * stream). Deliberately NOT the same as revertChanges(): for a new-file edit that + * path saves a dirty buffer as-is before deleting the file, which would write + * partial model output the task never approved - and leave it on disk if the + * delete then fails. Here the buffer is emptied FIRST, so the only bytes that can + * ever reach the placeholder file are none; the tab is then clean enough to close + * without a prompt, and the placeholder plus the directories this edit created are + * removed. + */ + async discardUnapprovedStream(): Promise { + if (!this.relPath || !this.activeDiffEditor) { + return + } + + const absolutePath = path.resolve(this.cwd, this.relPath) + const document = this.activeDiffEditor.document + + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + await this.closeAllDiffViews() + + if (document.isDirty) { + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ) + edit.replace(document.uri, fullRange, "") + await vscode.workspace.applyEdit(edit) + await document.save() + } + + await this.closeFileTab(absolutePath) + await fs.unlink(absolutePath) + + // Remove only the directories this edit created, in reverse order. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + } + } + async revertChanges(): Promise { if (!this.relPath || !this.activeDiffEditor) { return diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..a630c84c3f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1,3 +1,4 @@ +import * as fs from "fs/promises" import { DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" import * as vscode from "vscode" import * as path from "path" @@ -14,6 +15,10 @@ vi.mock("delay", () => ({ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), + // The abandoned-stream discard removes the placeholder file and the directories it + // created, so the discard path needs these two. + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), })) @@ -1845,5 +1850,56 @@ describe("DiffViewProvider", () => { expect(closeFileTab).not.toHaveBeenCalled() expect(vscode.window.showTextDocument).toHaveBeenCalled() }) + // An abandoned partial stream was never approved. revertChanges() saves a dirty + // new-file buffer before deleting the file, which would put partial model output on + // disk (and leave it there if the delete fails), so the discard path blanks the + // buffer first and only then saves the empty placeholder before removing it. + describe("discardUnapprovedStream method", () => { + it("never persists the unapproved buffer: blanks it, then deletes the placeholder", async () => { + const callOrder: string[] = [] + const document = { + isDirty: true, + getText: () => "partial model output", + positionAt: (offset: number) => ({ line: 0, character: offset }), + uri: { fsPath: mockTargetPath, path: mockTargetPath }, + save: vi.fn(async () => { + callOrder.push("save") + }), + } + Object.assign(diffViewProvider, { + relPath: "mock-target-file.ts", + activeDiffEditor: { document }, + editType: "create", + createdDirs: [], + closeAllDiffViews: vi.fn(async () => { + callOrder.push("closeDiffViews") + }), + closeFileTab: vi.fn(async () => { + callOrder.push("closeFileTab") + }), + }) + vi.mocked(vscode.workspace.applyEdit).mockImplementation(async (edit: unknown) => { + callOrder.push("applyEdit") + // The only content the discard may write is an empty buffer. + expect((edit as { replace: (uri: unknown, range: unknown, text: string) => void })).toBeDefined() + return true + }) + + await diffViewProvider.discardUnapprovedStream() + + expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "save", "closeFileTab"]) + // The placeholder for THIS relPath is what gets removed. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + }) + + it("does nothing when no abandoned view is open", async () => { + Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) }) }) + From ddd35071c9981ff407b80a299d66ac710970a20f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 19:17:23 +0800 Subject: [PATCH 16/29] fix(write-to-file): make partial-stream handling cancellation-aware handlePartial() creates the per-task stream state and registers the TaskAborted teardown, then awaits provider state, a filesystem probe and task.ask() before it touches the diff view. A cancellation during any of those awaits releases the state, but the in-flight delta kept going: it set editType, asked again and opened/updated the diff view for a task the user had already cancelled - resurrecting exactly the state the teardown released, and leaving an editor open with content that will never be executed. isPartialStreamStillLive(task, state) compares the map entry by identity (not presence) so a re-created entry for the same key - a new stream - is never written into by the old one. It is checked after each await and before each side effect. Test: 'stops before touching the diff view when the stream state is released during an in-flight await' - the state is released inside task.ask(), and open()/update() must not run while the state stays released. Pin: removing the guard after task.ask() fails that test. Local: writeToFileTool.spec + assistant-message + core/task = 849 passed / 5 skipped; tsc 0; eslint 0 err / 0 warn on both files. --- src/core/tools/WriteToFileTool.ts | 26 +++++++++++++++++++ .../tools/__tests__/writeToFileTool.spec.ts | 20 ++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 59e78679be..fe58853fe8 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -131,6 +131,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { this.taskPartialStreamState.delete(key) } + /** + * Whether this task's partial stream is still the live one. handlePartial() awaits + * provider state, a filesystem probe and task.ask() before it touches the diff view; a + * cancellation during any of those awaits runs the TaskAborted teardown (or a direct + * clearTaskState), which deletes this entry. Continuing would re-open a diff view and + * re-ask for a task the user already cancelled, resurrecting the state the teardown + * released. Identity, not presence: a re-created entry for the same key belongs to a new + * stream, and this one must not write into it. + */ + private isPartialStreamStillLive(task: Task, state: TaskPartialStreamState): boolean { + return this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) === state + } + private async resetDiffViewAfterWrite(task: Task): Promise { await task.diffViewProvider.reset().catch((resetError) => { console.error("Error resetting write_to_file diff view:", resetError) @@ -554,6 +567,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const provider = task.providerRef.deref() const state = await provider?.getState() + + // Cancelled while provider state was in flight: the teardown already + // released this task's stream state. + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + return + } const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, @@ -571,6 +590,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { fileExists = task.diffViewProvider.editType === "modify" } else { fileExists = await fileExistsAtPath(absolutePath) + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + return + } task.diffViewProvider.editType = fileExists ? "modify" : "create" } @@ -588,6 +610,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, block.partial).catch(() => {}) + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + return + } + if (newContent) { try { if (!task.diffViewProvider.isEditing) { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 631b6ea7c4..7bbb1a5d6c 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -534,6 +534,26 @@ describe("writeToFileTool", () => { expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) + it("stops before touching the diff view when the stream state is released during an in-flight await", async () => { + // A cancellation while task.ask() is in flight runs the TaskAborted teardown. The + // delta that was already in flight must not then re-open the diff view for a task + // the user cancelled - that resurrects the state the teardown just released. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + mockCline.diffViewProvider.open.mockClear() + mockCline.diffViewProvider.update.mockClear() + mockCline.ask.mockImplementation(async () => { + writeToFileTool.clearTaskState(mockCline) + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + it("does nothing when the task has no stream state to release", async () => { await writeToFileTool.teardownAbandonedStream(mockCline) expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() From 876a93b22fe845e9acf8b1b7334b87e4b8cec2e3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 00:30:04 +0800 Subject: [PATCH 17/29] fix(write-to-file): make abandoned-stream cleanup failure-safe and cancellation-aware CodeRabbit pre-merge rows at ddd35071c (2 warnings): - discardUnapprovedStream() unlinked the placeholder and removed createdDirs only after applyEdit()/document.save(). If either rejected, the caller caught, ran reset() (which drops relPath/createdDirs) and nothing could remove the artifacts a malformed streamed write_to_file had already created. The directory list is now snapshotted up front, the editor work is captured rather than propagated, and the unlink + reverse-order rmdir always run (ENOENT tolerated); the original failure is re-thrown afterwards so the caller still reports the rollback hazard. - handlePartial() checked stream identity before entering open()/update() but not after open(), so a delta already in flight could stream partial content into a diff view whose task had been cancelled. Added the liveness guard after open(). Tests: the discardUnapprovedStream suite now asserts the WorkspaceEdit.replace arguments (document uri + full-buffer range + empty replacement) and their order before save, a non-empty createdDirs fixture removed in reverse order after the unlink, the clean-buffer no-save case, and the applyEdit-rejects-still-cleans case; writeToFileTool.spec adds the cancel-during-open case. Negative controls: removing the guard -> 1 failed; propagating the editor error -> 1 failed; dropping the createdDirs loop -> 2 failed; dropping the isDirty check -> 1 failed; replacing with the streamed text instead of "" -> 1 failed; all restored -> green. --- src/core/tools/WriteToFileTool.ts | 8 ++ .../tools/__tests__/writeToFileTool.spec.ts | 22 ++++ src/integrations/editor/DiffViewProvider.ts | 72 +++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 108 ++++++++++++++---- 4 files changed, 173 insertions(+), 37 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index fe58853fe8..23a07ae5eb 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -620,6 +620,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { await task.diffViewProvider.open(relPath!) } + // Cancellation may land while open() is in flight: its abort handler has + // already torn the stream down (and may have reverted or closed this very + // diff view), so streaming the partial content into it now would resurrect a + // view for a task that no longer exists. + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + return + } + await task.diffViewProvider.update( everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, false, diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 7bbb1a5d6c..6d7e1bee95 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -554,6 +554,28 @@ describe("writeToFileTool", () => { expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) + + it("stops before updating the diff view when the task is cancelled while open() is in flight", async () => { + // open() is the first provider await after the ask. If TaskAborted lands while + // it is in flight, the teardown has already released this task's stream state + // (and may have reverted or closed this very view), so the in-flight delta must + // not stream partial content into a view for a task the user cancelled. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + mockCline.diffViewProvider.open.mockClear() + mockCline.diffViewProvider.update.mockClear() + mockCline.diffViewProvider.open.mockImplementationOnce(async () => { + writeToFileTool.clearTaskState(mockCline) + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) it("does nothing when the task has no stream state to release", async () => { await writeToFileTool.teardownAbandonedStream(mockCline) expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index df4552bd9d..4661ad191c 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -530,28 +530,66 @@ export class DiffViewProvider { const absolutePath = path.resolve(this.cwd, this.relPath) const document = this.activeDiffEditor.document + // Snapshot the directories this edit created BEFORE the editor work below, and + // clear the field so nothing else can act on them twice. If an await below + // rejects, the caller runs reset(), which drops relPath/createdDirs - this is + // then the only chance to remove what the abandoned edit left on disk. + const createdDirs = this.createdDirs + this.createdDirs = [] - this.disposeActiveEditorListener() - this.cancelDeferredScroll() - await this.closeAllDiffViews() + let editorFailure: unknown + try { + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + await this.closeAllDiffViews() - if (document.isDirty) { - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - document.positionAt(0), - document.positionAt(document.getText().length), - ) - edit.replace(document.uri, fullRange, "") - await vscode.workspace.applyEdit(edit) - await document.save() + if (document.isDirty) { + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ) + edit.replace(document.uri, fullRange, "") + await vscode.workspace.applyEdit(edit) + await document.save() + } + + await this.closeFileTab(absolutePath) + } catch (error) { + // Do NOT stop here: the placeholder and the created directories still have + // to go. The original failure is re-thrown once the artifacts are dealt with, + // so the caller still reports the rollback hazard instead of a silent success. + editorFailure = error } - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) + let cleanupFailure: unknown + try { + await fs.unlink(absolutePath).catch((error: unknown) => { + // open() creates the placeholder; if it is already gone there is nothing + // left to remove and the discard did its job. + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error + } + }) - // Remove only the directories this edit created, in reverse order. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) + // Remove only the directories this edit created, in reverse order. + for (let i = createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(createdDirs[i]).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error + } + }) + } + } catch (error) { + cleanupFailure = error + console.error("Error removing abandoned write_to_file artifacts:", error) + } + + if (editorFailure) { + throw editorFailure instanceof Error ? editorFailure : new Error(String(editorFailure)) + } + if (cleanupFailure) { + throw cleanupFailure instanceof Error ? cleanupFailure : new Error(String(cleanupFailure)) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index a630c84c3f..3b37a576d1 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1855,22 +1855,22 @@ describe("DiffViewProvider", () => { // disk (and leave it there if the delete fails), so the discard path blanks the // buffer first and only then saves the empty placeholder before removing it. describe("discardUnapprovedStream method", () => { - it("never persists the unapproved buffer: blanks it, then deletes the placeholder", async () => { - const callOrder: string[] = [] - const document = { - isDirty: true, - getText: () => "partial model output", - positionAt: (offset: number) => ({ line: 0, character: offset }), - uri: { fsPath: mockTargetPath, path: mockTargetPath }, - save: vi.fn(async () => { - callOrder.push("save") - }), - } + const makeAbandonedDocument = (callOrder: string[], isDirty = true) => ({ + isDirty, + getText: () => "partial model output", + positionAt: (offset: number) => ({ line: 0, character: offset }), + uri: { fsPath: mockTargetPath, path: mockTargetPath }, + save: vi.fn(async () => { + callOrder.push("save") + }), + }) + + const openAbandonedView = (document: unknown, createdDirs: string[], callOrder: string[]) => Object.assign(diffViewProvider, { relPath: "mock-target-file.ts", activeDiffEditor: { document }, editType: "create", - createdDirs: [], + createdDirs, closeAllDiffViews: vi.fn(async () => { callOrder.push("closeDiffViews") }), @@ -1878,28 +1878,96 @@ describe("DiffViewProvider", () => { callOrder.push("closeFileTab") }), }) - vi.mocked(vscode.workspace.applyEdit).mockImplementation(async (edit: unknown) => { + + it("never persists the unapproved buffer: blanks it with an empty replacement, then deletes the placeholder", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockImplementation(async () => { callOrder.push("applyEdit") - // The only content the discard may write is an empty buffer. - expect((edit as { replace: (uri: unknown, range: unknown, text: string) => void })).toBeDefined() return true }) - + await diffViewProvider.discardUnapprovedStream() - + expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "save", "closeFileTab"]) + // The only content the discard may write is an empty buffer, and it must be + // written to THIS document before the save that would otherwise persist the + // partial model output. + expect(mockWorkspaceEdit.replace).toHaveBeenCalledTimes(1) + const [replacedUri, , replacedText] = mockWorkspaceEdit.replace.mock.calls[0] + expect(replacedUri).toEqual(document.uri) + // The replacement covers the whole buffer: the range is built from + // positionAt(0) to positionAt(getText().length), so nothing can survive it. + expect(vi.mocked(vscode.Range)).toHaveBeenCalledWith( + { line: 0, character: 0 }, + { line: 0, character: "partial model output".length }, + ) + expect(replacedText).toBe("") + expect(mockWorkspaceEdit.delete).not.toHaveBeenCalled() // The placeholder for THIS relPath is what gets removed. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) }) - + + it("removes the directories this edit created in reverse order, after the placeholder unlink", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + const createdDirs = [`${mockCwd}/mock-dir`, `${mockCwd}/mock-dir/nested`] + openAbandonedView(document, createdDirs, callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledTimes(createdDirs.length) + // Reverse order, so a parent is never removed before the child inside it, and + // only the directories this edit created - never a pre-existing one. + expect(vi.mocked(fs.rmdir).mock.calls.map((c) => c[0])).toEqual([...createdDirs].reverse()) + const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] + for (const order of vi.mocked(fs.rmdir).mock.invocationCallOrder) { + expect(order).toBeGreaterThan(unlinkOrder) + } + }) + + it("does not save when the abandoned buffer is already clean", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder, false) + openAbandonedView(document, [], callOrder) + + await diffViewProvider.discardUnapprovedStream() + + expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() + expect(document.save).not.toHaveBeenCalled() + expect(mockWorkspaceEdit.replace).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledTimes(1) + }) + + it("still removes the placeholder and the created directories when the editor work rejects, and reports the failure", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + const createdDirs = [`${mockCwd}/mock-dir`] + openAbandonedView(document, createdDirs, callOrder) + vi.mocked(vscode.workspace.applyEdit).mockRejectedValue(new Error("applyEdit rejected")) + + // The caller (releaseAbandonedDiffView) treats a throw as "rollback hazard", so + // the failure must still surface - but only after the artifacts are gone, because + // the caller then runs reset(), which drops relPath/createdDirs and leaves + // nothing else able to remove them. + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) + }) + it("does nothing when no abandoned view is open", async () => { Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) - + await diffViewProvider.discardUnapprovedStream() - + expect(fs.unlink).not.toHaveBeenCalled() }) }) }) }) + From d585c63833899d13ae1c72771ae7d0880d807e0f Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:04:16 +0800 Subject: [PATCH 18/29] fix(write-to-file): route every pre-approval rollback through the new-file discard CodeRabbit review thread PRRT_kwDOSK-bwM6qcuIN (Major) at 876a93b22: only teardownAbandonedStream() used the safe path. cleanupFailedPartialStream(), onParameterParseFailure() and the pre-approval branch of the execute() catch all still went through revertDiffChangesBeforeReset() -> DiffViewProvider.revertChanges(), whose new-file branch SAVES the dirty buffer (unapproved partial model output) before unlinking, so a failed delete leaves that content on disk - for a .rooignore-denied path that is a write the policy forbids. revertDiffChangesBeforeReset() now delegates to releaseAbandonedDiffView(): modify edits keep revertChanges() (restoring real prior content is safe), new-file edits discard the buffer without ever persisting it. The console line for a failed rollback changes to 'Error releasing the abandoned write_to_file diff view:' for all callers. Tests: two new routing cases in writeToFileTool-partial-state-cleanup.spec (new-file -> discardUnapprovedStream and never revertChanges; modify -> revertChanges and never discard), and the 14 affected tests in writeToFileTool.spec.ts / the 5 rejecting mocks in the cleanup spec updated to the discard path. Negative controls: restoring the pre-fix revertChanges routing -> 4 failed; removing the modify branch -> 1 failed; restored -> 5 passed. Verification: writeToFileTool.spec 65 passed / 5 skipped, partial-state-cleanup 11 passed, core/tools 676 passed / 5 skipped, integrations/editor 82 passed plus the one known pre-existing local failure (saveChanges default-values, fails at pristine head too), tsc --noEmit 0 errors, eslint --prune-suppressions --max-warnings=0 clean on all five touched files with eslint-suppressions.json unchanged. --- src/core/tools/WriteToFileTool.ts | 25 +++---- ...teToFileTool-partial-state-cleanup.spec.ts | 45 ++++++++++-- .../tools/__tests__/writeToFileTool.spec.ts | 69 ++++++++++--------- 3 files changed, 87 insertions(+), 52 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 23a07ae5eb..13f6e914e8 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -156,20 +156,21 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * reset() clears the provider's state but leaves the diff document dirty with the * streamed content; a user save would then persist a write the task never completed * (denied or failed before approval). Must run BEFORE resetDiffViewAfterWrite(), - * since reset() clears the state revertChanges() relies on. No-op when no diff view - * is open. Failures are logged and reported through the return value: the caller - * must not treat the teardown as complete when this returns false, because the - * document may still hold the unapproved content, but the remaining cleanup (reset, - * per-task state teardown) still runs so the task is not left half-torn-down. + * since reset() clears the state the rollback relies on. No-op when no diff view is + * open. Failures are logged and reported through the return value: the caller must + * not treat the teardown as complete when this returns false, because the document + * may still hold the unapproved content, but the remaining cleanup (reset, per-task + * state teardown) still runs so the task is not left half-torn-down. */ private async revertDiffChangesBeforeReset(task: Task): Promise { - try { - await task.diffViewProvider.revertChanges() - return true - } catch (revertError) { - console.error("Error reverting write_to_file diff view changes:", revertError) - return false - } + // Every caller of this restores content the user never approved, so a new-file + // edit must NOT go through revertChanges(): its new-file branch saves the dirty + // buffer - unapproved partial model output - before deleting the file, so a + // failed delete leaves that content on disk (for a .rooignore-denied path that + // is a write the policy forbids). releaseAbandonedDiffView() keeps revertChanges() + // for modify edits, which restores the original content and is safe, and discards + // a new-file buffer without ever persisting it. + return this.releaseAbandonedDiffView(task) } private async finalizePartialToolAskAfterFailure(task: Task, text?: string): Promise { diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts index 2cc0d4d63c..a32112e661 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -17,6 +17,8 @@ interface CleanupTask { diffViewProvider: { reset: MockedFunction<() => Promise> revertChanges: MockedFunction<() => Promise> + discardUnapprovedStream: MockedFunction<() => Promise> + editType?: string } finalizePartialToolAsk: MockedFunction<() => Promise> say: MockedFunction<(...args: unknown[]) => Promise> @@ -31,6 +33,7 @@ function buildTask(taskId: string, instanceId: string): Task { diffViewProvider: { reset: vi.fn().mockResolvedValue(undefined), revertChanges: vi.fn().mockResolvedValue(undefined), + discardUnapprovedStream: vi.fn().mockResolvedValue(undefined), }, finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), say: vi.fn().mockResolvedValue(undefined), @@ -81,19 +84,19 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { it("logs and continues when reverting the diff document fails", async () => { const task = buildTask("revert-fails", "inst-4") const t = task as unknown as CleanupTask - t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + t.diffViewProvider.discardUnapprovedStream = vi.fn().mockRejectedValue(new Error("revert failed")) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) await writeToFileTool["revertDiffChangesBeforeReset"](task) - expect(errorSpy).toHaveBeenCalledWith("Error reverting write_to_file diff view changes:", expect.any(Error)) + expect(errorSpy).toHaveBeenCalledWith("Error releasing the abandoned write_to_file diff view:", expect.any(Error)) }) it("reverts while the recovery state exists and reports the hazard when the revert fails", async () => { const task = buildTask("revert-fails-teardown", "inst-6") const t = task as unknown as CleanupTask let stateSizeDuringRevert = -1 - t.diffViewProvider.revertChanges = vi.fn(async () => { + t.diffViewProvider.discardUnapprovedStream = vi.fn(async () => { // The recovery state must still be present while the rollback runs. stateSizeDuringRevert = writeToFileTool["taskPartialStreamState"].size throw new Error("revert failed") @@ -125,7 +128,7 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { it("reports the rollback hazard AND the retained streaming error, and returns true", async () => { const task = buildTask("revert-fails-with-stream-error", "inst-7") const t = task as unknown as CleanupTask - t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + t.diffViewProvider.discardUnapprovedStream = vi.fn().mockRejectedValue(new Error("revert failed")) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) const state = writeToFileTool["getTaskPartialStreamState"](task) const streamError = new Error("filesystem failure while streaming") @@ -149,7 +152,7 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { it("still reports the streaming error when the rollback warning itself fails", async () => { const task = buildTask("rollback-warning-fails", "inst-8") const t = task as unknown as CleanupTask - t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + t.diffViewProvider.discardUnapprovedStream = vi.fn().mockRejectedValue(new Error("revert failed")) t.say = vi.fn().mockRejectedValue(new Error("say failed")) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) const state = writeToFileTool["getTaskPartialStreamState"](task) @@ -174,7 +177,7 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { it("surfaces the rollback hazard from the failed-stream cleanup as well", async () => { const task = buildTask("failed-stream-cleanup", "inst-9") const t = task as unknown as CleanupTask - t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(new Error("revert failed")) + t.diffViewProvider.discardUnapprovedStream = vi.fn().mockRejectedValue(new Error("revert failed")) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) await writeToFileTool["cleanupFailedPartialStream"](task) @@ -195,4 +198,34 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { expect(errorSpy).toHaveBeenCalledWith("Error finalizing write_to_file partial tool ask:", expect.any(Error)) }) + + it("routes a new-file rollback through the discard path, never through revertChanges", async () => { + // Pre-approval cleanup restores content the user never approved. revertChanges()'s + // new-file branch SAVES that buffer before deleting the file, so a failed delete + // leaves unapproved model output on disk (for a .rooignore-denied path that is a + // write the policy forbids). The discard path never persists it. + const task = buildTask("new-file-rollback", "inst-10") + const t = task as unknown as CleanupTask + t.diffViewProvider.editType = "create" + + const rolledBack = await writeToFileTool["revertDiffChangesBeforeReset"](task) + + expect(t.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) + expect(t.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(rolledBack).toBe(true) + }) + + it("keeps revertChanges for a modify rollback so the original content is restored", async () => { + // A modify edit has real prior content on disk; restoring it through + // revertChanges() is safe and must not be replaced by the discard path. + const task = buildTask("modify-rollback", "inst-11") + const t = task as unknown as CleanupTask + t.diffViewProvider.editType = "modify" + + const rolledBack = await writeToFileTool["revertDiffChangesBeforeReset"](task) + + expect(t.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(t.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() + expect(rolledBack).toBe(true) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 6d7e1bee95..cdbe434fe6 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -170,6 +170,7 @@ describe("writeToFileTool", () => { update: vi.fn().mockResolvedValue(undefined), reset: vi.fn().mockResolvedValue(undefined), revertChanges: vi.fn().mockResolvedValue(undefined), + discardUnapprovedStream: vi.fn().mockResolvedValue(undefined), saveDirectly: vi.fn().mockResolvedValue(undefined), saveChanges: vi.fn().mockResolvedValue({ newProblemsMessage: "", @@ -294,10 +295,10 @@ describe("writeToFileTool", () => { } return mockCline }) - // Record the relative order of revertChanges() and reset(): vitest mocks expose + // Record the relative order of discardUnapprovedStream() and reset(): vitest mocks expose // no invocationCallOrder, so the ordering assertion uses this sequence. const diffViewCallOrder: string[] = [] - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") }) mockCline.diffViewProvider.reset.mockImplementation(async () => { @@ -320,7 +321,7 @@ describe("writeToFileTool", () => { // The denial finalizes without a text match: any open partial tool ask is closed. expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) // The denied write's streamed content must be reverted from the diff document - // BEFORE reset() clears the state revertChanges() relies on. + // BEFORE reset() clears the state discardUnapprovedStream() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -349,17 +350,17 @@ describe("writeToFileTool", () => { // payload is missing `content`. The missing-parameter branch must finalize the ask // (the spinner must not stick) and still perform the same diff-view revert / reset // and per-task-state cleanup as the other early-return paths. - // Record the relative order of revertChanges() and reset(): vitest mocks expose + // Record the relative order of discardUnapprovedStream() and reset(): vitest mocks expose // no invocationCallOrder, so the ordering assertion uses this sequence. The // revert mock awaits a deferred so the test proves the branch AWAITs - // revertChanges() before reset(): with the revert still pending, reset() must + // discardUnapprovedStream() before reset(): with the revert still pending, reset() must // not have run yet. const diffViewCallOrder: string[] = [] let resolveRevert: () => void = () => {} const revertDeferred = new Promise((resolve) => { resolveRevert = resolve }) - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") await revertDeferred }) @@ -375,12 +376,12 @@ describe("writeToFileTool", () => { }) await streamPartialAsk() - // The missing-parameter branch awaits revertChanges() before reset(): with the + // The missing-parameter branch awaits discardUnapprovedStream() before reset(): with the // deferred revert still pending, reset() must not have run yet. const executePromise = executeWriteFileTool({ content: undefined }, { fileExists: false }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() resolveRevert() @@ -390,7 +391,7 @@ describe("writeToFileTool", () => { // The missing-parameter path finalizes without a text match: any open partial ask is closed. expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) // The streamed content of the failed write must be reverted from the diff - // document before reset() clears the state revertChanges() relies on. + // document before reset() clears the state discardUnapprovedStream() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() // The per-task stream state must be gone, not just the diff view: the entry and its @@ -403,14 +404,14 @@ describe("writeToFileTool", () => { // Same scenario with the `path` field missing: the missing-`path` branch must run // the identical partial-ask + diff-view + per-task-state cleanup. As in the // content-missing test above, the revert mock awaits a deferred so the test - // proves the branch AWAITs revertChanges() before reset(): with the revert + // proves the branch AWAITs discardUnapprovedStream() before reset(): with the revert // still pending, reset() must not have run yet. const diffViewCallOrder: string[] = [] let resolveRevert: () => void = () => {} const revertDeferred = new Promise((resolve) => { resolveRevert = resolve }) - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") await revertDeferred }) @@ -426,12 +427,12 @@ describe("writeToFileTool", () => { }) await streamPartialAsk() - // The missing-parameter branch awaits revertChanges() before reset(): with the + // The missing-parameter branch awaits discardUnapprovedStream() before reset(): with the // deferred revert still pending, reset() must not have run yet. const executePromise = executeWriteFileTool({ path: undefined }, { fileExists: false }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() resolveRevert() @@ -440,7 +441,7 @@ describe("writeToFileTool", () => { expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "path") expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) // The diff document must be reverted before reset() clears the state - // revertChanges() relies on. + // discardUnapprovedStream() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() // The per-task stream state must be gone, not just the diff view: the entry and its @@ -916,7 +917,7 @@ describe("writeToFileTool", () => { // streaming failure above already reverted it once (revert + reset), // and the parse path runs the same cleanup again because execute() // never runs on this path. - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(2) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(2) expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(2) // Per-task state torn down at this boundary: guard cleared, the exact // registered abort listener detached. @@ -977,7 +978,7 @@ describe("writeToFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) // The diff document is restored by the parse path itself (no streaming // failure happened, so this is the only revert + reset in the test). - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(1) // Per-task state torn down: guard cleared, exact listener detached. expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) @@ -1173,10 +1174,10 @@ describe("writeToFileTool", () => { mockCline.diffViewProvider.open.mockRejectedValue( Object.assign(new Error("EACCES: permission denied, open '/ro/test.py'"), { code: "EACCES" }), ) - // Record the relative order of revertChanges() and reset() (vitest mocks expose + // Record the relative order of discardUnapprovedStream() and reset() (vitest mocks expose // no invocationCallOrder). const diffViewCallOrder: string[] = [] - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") }) mockCline.diffViewProvider.reset.mockImplementation(async () => { @@ -1194,7 +1195,7 @@ describe("writeToFileTool", () => { // a wrong argument (e.g. relPath) would leave the spinner stuck. expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) // The failed write's streamed content must be reverted before reset() clears the - // state revertChanges() relies on. + // state discardUnapprovedStream() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() }) @@ -1204,10 +1205,10 @@ describe("writeToFileTool", () => { mockCline.diffViewProvider.update.mockRejectedValue( Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), ) - // Record the relative order of revertChanges() and reset() (vitest mocks expose + // Record the relative order of discardUnapprovedStream() and reset() (vitest mocks expose // no invocationCallOrder). const diffViewCallOrder: string[] = [] - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") }) mockCline.diffViewProvider.reset.mockImplementation(async () => { @@ -1224,7 +1225,7 @@ describe("writeToFileTool", () => { // a wrong argument (e.g. relPath) would leave the spinner stuck. expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) // The failed write's streamed content must be reverted before reset() clears the - // state revertChanges() relies on. + // state discardUnapprovedStream() relies on. expect(diffViewCallOrder).toEqual(["revert", "reset"]) expect(mockHandleError).not.toHaveBeenCalled() }) @@ -1375,10 +1376,10 @@ describe("writeToFileTool", () => { mockedCreateDirectoriesForFile.mockRejectedValue( Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), ) - // Record the relative order of revertChanges() and reset() (vitest mocks expose + // Record the relative order of discardUnapprovedStream() and reset() (vitest mocks expose // no invocationCallOrder). const diffViewCallOrder: string[] = [] - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") }) mockCline.diffViewProvider.reset.mockImplementation(async () => { @@ -1396,7 +1397,7 @@ describe("writeToFileTool", () => { }) it("continues cleanup when reverting the diff document fails before approval", async () => { - // Pins the .catch arm on revertChanges() in revertDiffChangesBeforeReset(): a failed + // Pins the .catch arm on discardUnapprovedStream() in revertDiffChangesBeforeReset(): a failed // revert (e.g. the diff view was already closed) must only be logged so the // remaining cleanup (diff view reset + per-task state teardown) always completes. const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) @@ -1411,7 +1412,7 @@ describe("writeToFileTool", () => { mockedCreateDirectoriesForFile.mockRejectedValue( Object.assign(new Error("EACCES: permission denied, mkdir '/ro'"), { code: "EACCES" }), ) - mockCline.diffViewProvider.revertChanges.mockRejectedValue(new Error("revert failed")) + mockCline.diffViewProvider.discardUnapprovedStream.mockRejectedValue(new Error("revert failed")) // Stream two deltas so the diff view opens with the unapproved content... await executeWriteFileTool({}, { fileExists: false, isPartial: true }) @@ -1421,7 +1422,7 @@ describe("writeToFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) expect(consoleErrorSpy).toHaveBeenCalledWith( - "Error reverting write_to_file diff view changes:", + "Error releasing the abandoned write_to_file diff view:", expect.any(Error), ) // The diff view is still reset and the per-task stream state still torn down. @@ -1462,7 +1463,7 @@ describe("writeToFileTool", () => { // tool ask is closed. expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( "Error finalizing write_to_file partial tool ask:", @@ -1509,7 +1510,7 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() expect(mockHandleError).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -1533,7 +1534,7 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() expect(mockHandleError).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( @@ -1603,7 +1604,7 @@ describe("writeToFileTool", () => { // path closes whichever partial tool ask is open) to dismiss the spinner expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) // The write was never approved, so the diff document is reverted before reset - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) }) it("runs diff cleanup when handleError rejects", async () => { @@ -1613,7 +1614,7 @@ describe("writeToFileTool", () => { // could persist the failed write. The handleError rejection itself propagates // (it is not swallowed by the cleanup). As in the missing-parameter tests // above, the revert mock awaits a deferred so the test proves the cleanup - // AWAITs revertChanges() before reset(): with the revert still pending, + // AWAITs discardUnapprovedStream() before reset(): with the revert still pending, // reset() must not have run yet. mockHandleError.mockRejectedValue(new Error("handleError rejected (aborted task)")) mockedCreateDirectoriesForFile.mockRejectedValue( @@ -1624,7 +1625,7 @@ describe("writeToFileTool", () => { const revertDeferred = new Promise((resolve) => { resolveRevert = resolve }) - mockCline.diffViewProvider.revertChanges.mockImplementation(async () => { + mockCline.diffViewProvider.discardUnapprovedStream.mockImplementation(async () => { diffViewCallOrder.push("revert") await revertDeferred }) @@ -1638,7 +1639,7 @@ describe("writeToFileTool", () => { // handleError was attempted with the write context and the cleanup has reached // the deferred revert... expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) // ...and while the revert is still pending, reset() must not have run yet. expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() From 14fd87fda4a1b74cdd631ceb7e71f1f14d375611 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 02:24:20 +0800 Subject: [PATCH 19/29] fix(p1066-u7): address the d585c6383 review - artifact cleanup, abandoned-stream exits, metadata retry CodeRabbit review at d585c6383 (1 error, 2 warnings, 2 new threads). All four are in files this unit owns and all four are real: 1. Lifecycle / thread PRRT_kwDOSK-bwM6qfWti - DiffViewProvider.open() creates the directories (line 130) and the empty placeholder (line 134) BEFORE openDiffEditor() assigns activeDiffEditor (line 173). If that call rejects (10s timeout, failed vscode.diff), discardUnapprovedStream() hit its `!activeDiffEditor` early return and left the placeholder plus createdDirs on disk. The guard is now `!relPath` only: the buffer/tab work is editor-conditional, the artifact cleanup is not. 2. Regression Evidence - added the missing cleanup-failure coverage: non-ENOENT fs.unlink and fs.rmdir failures each log 'Error removing abandoned write_to_file artifacts:' and reject with that error; ENOENT stays tolerated; when the editor work AND the cleanup both fail, the editor failure is the one reported. 3. Thread PRRT_kwDOSK-bwM6qfWte - the validateToolUse catch (e.g. a mode file restriction on the target path) and the tool-repetition break both exit a completed block without handle(), so the per-task stream state, its TaskAborted listener and any streamed diff view leaked. Both now call teardownAbandonedStream() like the missing-nativeArgs guard does. 4. Persistence Integrity - saveClineMessages() still reports the MESSAGE write, but the metadata / task-history stage is now its own method (persistTaskMetadata) with its own result, and a failure records pendingTaskMetadataRepair so disposeOnce() performs one AWAITED retry before the task stops. A metadata failure is therefore neither reported as a failed message write nor silently dropped. Negative controls: editor-gated early return restored -> 1 failed; each ENOENT-tolerance arm swallowed -> 1 failed each (both arms -> 2 failed); the two teardowns removed -> 2 failed; dispose retry removed -> 1 failed; all restored -> green. Verification: integrations/editor 87 passed plus the one known pre-existing local failure (saveChanges default-values, red at pristine head too), core/tools 676 passed / 5 skipped, core/task 687 passed, core/assistant-message 101 passed, tsc --noEmit 0 errors, eslint --prune-suppressions --max-warnings=0 clean on all six touched files with eslint-suppressions.json unchanged. --- ...sistantMessage-missing-native-args.spec.ts | 73 ++++++++++++++++- .../presentAssistantMessage.ts | 14 ++++ src/core/task/Task.ts | 59 +++++++++++--- src/core/task/__tests__/Task.spec.ts | 36 +++++++++ src/integrations/editor/DiffViewProvider.ts | 51 ++++++------ .../editor/__tests__/DiffViewProvider.spec.ts | 79 +++++++++++++++++++ 6 files changed, 279 insertions(+), 33 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts index a7d9e90aa5..688807b22e 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" -import { isValidToolName } from "../../tools/validateToolUse" +import { isValidToolName, validateToolUse } from "../../tools/validateToolUse" const mockTeardown = vi.hoisted(() => vi.fn()) const mockWriteHandle = vi.hoisted(() => vi.fn()) @@ -20,6 +20,7 @@ vi.mock("@roo-code/telemetry", () => ({ instance: { captureToolUsage: vi.fn(), captureConsecutiveMistakeError: vi.fn(), + captureException: vi.fn(), }, }, })) @@ -59,6 +60,8 @@ describe("presentAssistantMessage - finalized block without nativeArgs", () => { didRejectTool: false, didAlreadyUseTool: false, consecutiveMistakeCount: 0, + consecutiveMistakeLimit: 3, + apiConfiguration: { apiProvider: "test-provider" }, clineMessages: [], getTaskMode: vi.fn().mockResolvedValue("code"), api: { getModel: () => ({ id: "test-model", info: {} }) }, @@ -99,4 +102,72 @@ describe("presentAssistantMessage - finalized block without nativeArgs", () => { expect(mockTeardown).toHaveBeenCalledTimes(1) expect(mockTeardown).toHaveBeenCalledWith(mockTask) }) + + it("tears the stream state down when tool validation rejects the call", async () => { + // A mode file restriction (or any validateToolUse failure) lands here. The block + // streamed partial deltas - handlePartial ran - and this exit bypasses handle(), so + // without the teardown the per-task stream state, its TaskAborted listener and any + // streamed diff view leak into the next API request. + mockTask.assistantMessageContent = [ + { + type: "tool_use", + name: "write_to_file", + params: { path: "restricted.ts", content: "partial model output" }, + partial: false, + id: "toolu_2", + nativeArgs: { path: "restricted.ts", content: "partial model output" }, + }, + ] + vi.mocked(validateToolUse).mockImplementationOnce(() => { + throw new Error("write_to_file is not allowed to write restricted.ts in this mode") + }) + + await presentAssistantMessage(mockTask as unknown as Parameters[0]) + + expect(mockWriteHandle).not.toHaveBeenCalled() + expect(userMessageContent).toEqual([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "toolu_2", + is_error: true, + content: expect.stringContaining("restricted.ts"), + }), + ]) + expect(mockTeardown).toHaveBeenCalledTimes(1) + expect(mockTeardown).toHaveBeenCalledWith(mockTask) + }) + + it("tears the stream state down when the tool repetition limit stops the call", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + name: "write_to_file", + params: { path: "same.ts", content: "same content" }, + partial: false, + id: "toolu_3", + nativeArgs: { path: "same.ts", content: "same content" }, + }, + ] + vi.mocked(mockTask.toolRepetitionDetector as unknown as { check: unknown }).check = vi + .fn() + .mockReturnValue({ + allowExecution: false, + askUser: { messageKey: "tool_repetition", messageDetail: "write_to_file" }, + }) + + await presentAssistantMessage(mockTask as unknown as Parameters[0]) + + expect(mockWriteHandle).not.toHaveBeenCalled() + // The repetition exit reports through the local pushToolResult, which wraps the + // message in the tool-error envelope rather than the provider is_error flag. + expect(userMessageContent).toEqual([ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "toolu_3", + content: expect.stringContaining("repetition limit reached"), + }), + ]) + expect(mockTeardown).toHaveBeenCalledTimes(1) + expect(mockTeardown).toHaveBeenCalledWith(mockTask) + }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 2a0ba88990..9eb964aa7d 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -767,6 +767,14 @@ export async function presentAssistantMessage(cline: Task) { error.message, ) + // Same leak as the missing-nativeArgs guard: the block streamed partial + // deltas (handlePartial ran), this exit bypasses handle()/execute(), so the + // per-task stream state and any open diff view must be released here. A + // mode file restriction blocking the target path lands exactly here. + if (block.name === "write_to_file") { + await writeToFileTool.teardownAbandonedStream(cline) + } + break } @@ -834,6 +842,12 @@ export async function presentAssistantMessage(cline: Task) { `Tool call repetition limit reached for ${block.name}. Please try a different approach.`, ), ) + + // The repetition exit also bypasses handle(): release the stream state and + // diff view the partial deltas created, or they leak into the next request. + if (block.name === "write_to_file") { + await writeToFileTool.teardownAbandonedStream(cline) + } break } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 413bfc56a8..78597a5c8b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -380,6 +380,16 @@ export class Task extends EventEmitter implements TaskLike { */ private taskApiConfigReady: Promise + /** + * Set when the derived metadata / task-history stage of a save failed while the message + * write itself succeeded. Drives one awaited retry in disposeOnce() so a task being torn + * down does not leave its history entry stale; cleared on the next successful metadata + * stage. + * + * @private + */ + private pendingTaskMetadataRepair: boolean = false + providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false @@ -1680,11 +1690,13 @@ export class Task extends EventEmitter implements TaskLike { /** * Persist the message array, then refresh the derived metadata / task-history entries. * - * The returned boolean reflects the message write only: `saveTaskMessages` failure - * leaves the on-disk record stale, so callers gating UI updates on durable state must - * skip them. Metadata / task-history stage failures are logged and swallowed — the - * message array is already persisted, and the next save recomputes and re-emits the - * metadata. + * The two stages report separately on purpose. The returned boolean reflects the MESSAGE + * WRITE: `saveTaskMessages` failure leaves the on-disk record stale, so callers gating + * UI updates on durable state must skip them. A metadata / task-history failure does NOT + * turn that result false — the message array really is persisted, and reporting it as + * failed would make callers skip a webview update that is safe. It is not silently + * dropped either: `persistTaskMetadata()` records the failure and `disposeOnce()` retries + * it once, awaited, before the task stops. * * `merge` (default `true`) is passed through to `saveTaskMessages`: the in-memory * snapshot is merged with the on-disk record. `overwriteClineMessages` passes `false` @@ -1703,6 +1715,20 @@ export class Task extends EventEmitter implements TaskLike { return false } + await this.persistTaskMetadata() + + return true + } + + /** + * Recompute the derived task metadata and write the task-history entry for messages that + * are already on disk. Returns whether THIS stage succeeded — deliberately separate from + * `saveClineMessages()` so a metadata failure is never reported as a failed message write. + * + * A failure is recorded in `pendingTaskMetadataRepair` so `disposeOnce()` gets one awaited + * retry before shutdown; any later `saveClineMessages()` also recomputes it from scratch. + */ + private async persistTaskMetadata(): Promise { try { if (this._taskApiConfigName === undefined) { await this.taskApiConfigReady @@ -1731,14 +1757,16 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) + this.pendingTaskMetadataRepair = false + return true } catch (error) { - // The message array was persisted above; a metadata or task-history failure must - // not mask that write (see the method docs). The next saveClineMessages() call - // recomputes and re-emits the metadata update. + // The message array is already durable; a metadata or task-history failure must not + // mask that write (see saveClineMessages). Record it so the retry in disposeOnce() + // runs even if no further save happens before the task is torn down. + this.pendingTaskMetadataRepair = true console.error("Failed to save task metadata:", error) + return false } - - return true } private findMessageByTimestamp(ts: number): ClineMessage | undefined { @@ -3395,6 +3423,17 @@ export class Task extends EventEmitter implements TaskLike { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() + // A save whose metadata / task-history stage failed leaves the history entry behind + // the messages that are already on disk. Give that stage one awaited chance to catch + // up before the task stops serving, so shutdown does not persist a stale history item. + if (this.pendingTaskMetadataRepair) { + try { + await this.persistTaskMetadata() + } catch (error) { + console.error("Failed to retry task metadata during dispose:", error) + } + } + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 24d77c671a..c281a59e6d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6230,6 +6230,42 @@ describe("Cline", () => { historySpy.mockRestore() }) + it("retries the failed metadata / task-history stage once, awaited, during dispose", async () => { + // A metadata or task-history failure is reported separately from the message write, + // but it must not simply be dropped: the failure is recorded and dispose() awaits + // one retry before the task stops serving, so a task torn down after a partially + // successful save does not leave its history entry behind the messages that are + // already on disk. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const historySpy = vi + .spyOn(mockProvider, "updateTaskHistory") + .mockRejectedValueOnce(new Error("history stage unavailable")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // The message write succeeded, so the save still reports success even though the + // metadata stage failed - and the failure is logged, not swallowed. + await expect(getTaskTestAccess(task).saveClineMessages()).resolves.toBe(true) + expect(historySpy).toHaveBeenCalledTimes(1) + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save task metadata:", expect.any(Error)) + + await task.dispose() + + // dispose() awaited the retry: by the time it resolves the metadata stage has run + // again with the recomputed history item for this task. + expect(historySpy).toHaveBeenCalledTimes(2) + // The history record is keyed by `id` (the task id), not a taskId field. + expect(historySpy.mock.calls[1][0]).toEqual(expect.objectContaining({ id: task.taskId })) + + historySpy.mockRestore() + }) + it("finalizePartialToolAsk skips the webview update when the message write itself fails", async () => { // Complements the later-stage-failure test above by failing the first save // stage: with the real task directory removed, safeWriteJson's fs.access diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 4661ad191c..20c1d39b0b 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -524,12 +524,11 @@ export class DiffViewProvider { * removed. */ async discardUnapprovedStream(): Promise { - if (!this.relPath || !this.activeDiffEditor) { + if (!this.relPath) { return } const absolutePath = path.resolve(this.cwd, this.relPath) - const document = this.activeDiffEditor.document // Snapshot the directories this edit created BEFORE the editor work below, and // clear the field so nothing else can act on them twice. If an await below // rejects, the caller runs reset(), which drops relPath/createdDirs - this is @@ -538,28 +537,36 @@ export class DiffViewProvider { this.createdDirs = [] let editorFailure: unknown - try { - this.disposeActiveEditorListener() - this.cancelDeferredScroll() - await this.closeAllDiffViews() + // open() creates the directories and the empty placeholder BEFORE it assigns + // activeDiffEditor (openDiffEditor() can reject on its 10s timeout or a failed + // vscode.diff call), so an abandoned create can leave artifacts on disk with no + // editor at all. Only the buffer and tab work needs the editor; the artifact + // cleanup below runs either way. + if (this.activeDiffEditor) { + const document = this.activeDiffEditor.document + try { + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + await this.closeAllDiffViews() + + if (document.isDirty) { + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ) + edit.replace(document.uri, fullRange, "") + await vscode.workspace.applyEdit(edit) + await document.save() + } - if (document.isDirty) { - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - document.positionAt(0), - document.positionAt(document.getText().length), - ) - edit.replace(document.uri, fullRange, "") - await vscode.workspace.applyEdit(edit) - await document.save() + await this.closeFileTab(absolutePath) + } catch (error) { + // Do NOT stop here: the placeholder and the created directories still have + // to go. The original failure is re-thrown once the artifacts are dealt with, + // so the caller still reports the rollback hazard instead of a silent success. + editorFailure = error } - - await this.closeFileTab(absolutePath) - } catch (error) { - // Do NOT stop here: the placeholder and the created directories still have - // to go. The original failure is re-thrown once the artifacts are dealt with, - // so the caller still reports the rollback hazard instead of a silent success. - editorFailure = error } let cleanupFailure: unknown diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 3b37a576d1..b884ce9121 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1959,6 +1959,85 @@ describe("DiffViewProvider", () => { expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) }) + it("removes the placeholder and created directories even when open() failed before activeDiffEditor was assigned", async () => { + // open() creates the directories (line 130) and the empty placeholder (line 134) + // BEFORE openDiffEditor() assigns activeDiffEditor (line 173). If that call rejects + // - the 10s timeout or a failed vscode.diff - an abandoned create therefore has + // artifacts on disk and no editor, and the early return must not skip the cleanup. + Object.assign(diffViewProvider, { + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs: [`${mockCwd}/mock-dir`], + }) + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) + // Nothing editor-side ran, and nothing threw: the artifacts are the whole job here. + expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() + // Bracket access for the private field: the snapshot must be consumed, so a second + // discard cannot try to remove the same directories again. + expect(diffViewProvider["createdDirs"]).toEqual([]) + }) + + it("logs and rejects when the placeholder unlink fails for a reason other than ENOENT", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + const permissionError = Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + vi.mocked(fs.unlink).mockRejectedValueOnce(permissionError) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("EPERM: operation not permitted") + + expect(errorSpy).toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", permissionError) + }) + + it("logs and rejects when removing a created directory fails for a reason other than ENOENT", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + const notEmpty = Object.assign(new Error("ENOTEMPTY: directory not empty"), { code: "ENOTEMPTY" }) + vi.mocked(fs.rmdir).mockRejectedValueOnce(notEmpty) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("ENOTEMPTY: directory not empty") + + expect(errorSpy).toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", notEmpty) + }) + + it("tolerates an already-deleted placeholder and directories (ENOENT) without throwing", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fs.rmdir).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() + + expect(errorSpy).not.toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", expect.anything()) + }) + + it("reports the editor failure rather than the cleanup failure when both happen", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockRejectedValue(new Error("applyEdit rejected")) + vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("EPERM"), { code: "EPERM" })) + vi.spyOn(console, "error").mockImplementation(() => {}) + + // The caller reports a rollback hazard from the thrown error, so the ORIGINAL failure + // is what must surface; the cleanup failure is still logged for the operator. + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") + }) + it("does nothing when no abandoned view is open", async () => { Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) From 475d9e66bf8973d38014cb4b7d7719d42a812fa3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 02:47:33 +0800 Subject: [PATCH 20/29] fix(task): do not let the dispose-time metadata retry block teardown The retry added in 14fd87fda calls persistTaskMetadata(), which awaits taskApiConfigReady when the task's api-config name has not been initialized yet. For a task whose async initialization never settles, that turns a dispose-time repair into a hung teardown. The retry now also requires an initialized api config; a save whose metadata stage never ran has nothing to catch up with anyway. Test: `does not block teardown when a pending metadata repair has no initialized api config` races dispose() against a 2s timeout with taskApiConfigReady left unsettled. Negative control: dropping the `_taskApiConfigName !== undefined` clause -> 1 failed (dispose times out); restored -> green. Verification: core/task 688 passed, tsc --noEmit 0 errors, eslint clean on both files with src/eslint-suppressions.json unchanged. --- src/core/task/Task.ts | 5 ++++- src/core/task/__tests__/Task.spec.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 78597a5c8b..be3abf4ee1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3426,7 +3426,10 @@ export class Task extends EventEmitter implements TaskLike { // A save whose metadata / task-history stage failed leaves the history entry behind // the messages that are already on disk. Give that stage one awaited chance to catch // up before the task stops serving, so shutdown does not persist a stale history item. - if (this.pendingTaskMetadataRepair) { + // Skipped when the api-config initialization never completed: persistTaskMetadata() + // awaits taskApiConfigReady, and a never-settling initialization would block teardown. + // Nothing was written for that save's metadata stage anyway, so there is nothing to repair. + if (this.pendingTaskMetadataRepair && this._taskApiConfigName !== undefined) { try { await this.persistTaskMetadata() } catch (error) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c281a59e6d..e27be0baee 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6266,6 +6266,33 @@ describe("Cline", () => { historySpy.mockRestore() }) + it("does not block teardown when a pending metadata repair has no initialized api config", async () => { + // persistTaskMetadata() awaits taskApiConfigReady. For a task whose async api-config + // initialization never settles, an unconditional dispose-time retry would hang the + // teardown, so the retry is skipped when the config never initialized. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Bracket access for the private fields: this is the pathological construction where + // the async api-config initialization never resolves, which no public API can produce. + task["_taskApiConfigName"] = undefined + task["taskApiConfigReady"] = new Promise(() => {}) + task["pendingTaskMetadataRepair"] = true + + const outcome = await Promise.race([ + task.dispose().then(() => "disposed"), + new Promise((resolve) => setTimeout(() => resolve("timed out"), 2000)), + ]) + + expect(outcome).toBe("disposed") + }) + it("finalizePartialToolAsk skips the webview update when the message write itself fails", async () => { // Complements the later-stage-failure test above by failing the first save // stage: with the real task directory removed, safeWriteJson's fs.access From 3a00650915df0ebd6c790336ffb89a393b9be626 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 03:25:01 +0800 Subject: [PATCH 21/29] fix(p1066-u7): address the 475d9e66b review - placeholder ownership, cancelled-stream catch, settled-config retry 1) Data integrity (Major): discardUnapprovedStream() unlinked path.resolve(cwd, relPath), but relPath survives reset() and saveDirectly() sets it for a file that WAS written with approval. An approved write_to_file to A, then a new write_to_file whose block exits through a rollback path before open() ever ran, therefore deleted A. open() now records the placeholder it wrote in a private placeholderPath (cleared by reset(), by saveChanges() once the content is approved, and after any unlink), and the discard removes only that tracked path. The editor-side work is unchanged: the unapproved buffer is still blanked and the tab closed. 2) Lifecycle (Warning): a cancellation that lands while open()/update() is in flight runs the TaskAborted teardown (releasing the stream state and reverting or closing this very view) and can also reject the call in flight. The catch then marked the already-released state failed, finalized the ask and ran the failed-stream cleanup a second time. It now checks isPartialStreamStillLive() first and lets the teardown own the outcome. 3) Persistence (Error): the dispose-time metadata retry added in 475d9e66b gated on _taskApiConfigName !== undefined, which drops the retry for every history task saved before the api config was recorded. The gate is now taskApiConfigReadySettled - a new private flag set when the initialization promise settles (resolve or reject), observed without replacing the promise so rejections still reach other awaiters. Teardown is still protected against an initialization that never settles. 4) Test quality: the two 'keeps approved diff content' tests asserted only revertChanges not.toHaveBeenCalled(), which cannot fail for fileExists:false after the routing change; both now also assert discardUnapprovedStream not.toHaveBeenCalled(). The teardownAbandonedStream JSDoc was dangling above releaseAbandonedDiffView() and is now attached to its method. Negative controls: unconditional unlink (ownership guard dropped) -> 1 failed; catch liveness guard removed -> 1 failed; retry gated on the value again -> 1 failed (legacy-config test); retry ungated -> 1 failed (dispose times out); writeApproved guard dropped -> 2 failed (the new assertions are not vacuous). All restored -> green. Verification: core/tools 677 passed / 5 skipped, core/task 689 passed, integrations/editor 88 passed (plus the known pre-existing local-only saveChanges failure), tsc --noEmit 0 errors, eslint --prune-suppressions --max-warnings=0 clean on all six touched files with src/eslint-suppressions.json unchanged. --- src/core/task/Task.ts | 31 +++++++++++-- src/core/task/__tests__/Task.spec.ts | 37 ++++++++++++++- src/core/tools/WriteToFileTool.ts | 46 ++++++++++++------- .../tools/__tests__/writeToFileTool.spec.ts | 36 +++++++++++++++ src/integrations/editor/DiffViewProvider.ts | 40 +++++++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 31 +++++++++++++ 6 files changed, 191 insertions(+), 30 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index be3abf4ee1..9ffff7f5a5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -379,6 +379,14 @@ export class Task extends EventEmitter implements TaskLike { * @private */ private taskApiConfigReady: Promise + /** + * Whether taskApiConfigReady has settled. The dispose-time metadata retry needs this + * rather than a check on _taskApiConfigName: a legacy history item legitimately has no + * stored api config while its initialization completed, and that task still needs the + * retry. Only a promise that never settles may suppress it, because + * persistTaskMetadata() awaits it and teardown must not block on it. + */ + private taskApiConfigReadySettled = false /** * Set when the derived metadata / task-history stage of a save failed while the message @@ -706,12 +714,16 @@ export class Task extends EventEmitter implements TaskLike { this._taskApiConfigName = handoffExecutionContext.apiConfigName this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() + this.taskApiConfigReadySettled = true TelemetryService.instance.captureTaskCreated(this.taskId) } else if (historyItem) { this._taskMode = historyItem.mode || defaultModeSlug this._taskApiConfigName = historyItem.apiConfigName this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() + // historyItem.apiConfigName is undefined for tasks saved before the api config was + // recorded. That is a completed initialization, not a pending one. + this.taskApiConfigReadySettled = true TelemetryService.instance.captureTaskRestarted(this.taskId) } else { // For new tasks, don't set the mode/apiConfigName yet - wait for async initialization. @@ -719,6 +731,16 @@ export class Task extends EventEmitter implements TaskLike { this._taskApiConfigName = undefined this.taskModeReady = this.initializeTaskMode(provider) this.taskApiConfigReady = this.initializeTaskApiConfigName(provider) + // Observe settlement without replacing the promise: a rejection must still reach + // every other awaiter of taskApiConfigReady. + void this.taskApiConfigReady.then( + () => { + this.taskApiConfigReadySettled = true + }, + () => { + this.taskApiConfigReadySettled = true + }, + ) TelemetryService.instance.captureTaskCreated(this.taskId) } @@ -3426,10 +3448,11 @@ export class Task extends EventEmitter implements TaskLike { // A save whose metadata / task-history stage failed leaves the history entry behind // the messages that are already on disk. Give that stage one awaited chance to catch // up before the task stops serving, so shutdown does not persist a stale history item. - // Skipped when the api-config initialization never completed: persistTaskMetadata() - // awaits taskApiConfigReady, and a never-settling initialization would block teardown. - // Nothing was written for that save's metadata stage anyway, so there is nothing to repair. - if (this.pendingTaskMetadataRepair && this._taskApiConfigName !== undefined) { + // Skipped while taskApiConfigReady has not settled: persistTaskMetadata() awaits + // it, and an initialization that never settles would block teardown. A task that merely + // has no stored api config name (a history entry saved before it was recorded) HAS + // settled, and still gets its retry. + if (this.pendingTaskMetadataRepair && this.taskApiConfigReadySettled) { try { await this.persistTaskMetadata() } catch (error) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index e27be0baee..4c8c708445 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6266,10 +6266,43 @@ describe("Cline", () => { historySpy.mockRestore() }) - it("does not block teardown when a pending metadata repair has no initialized api config", async () => { + it("still retries for a task whose stored api config is legitimately absent", async () => { + // Tasks saved before the api config was recorded have apiConfigName undefined. Their + // initialization has completed, so the dispose-time repair must still run: gating it on + // the value rather than on the settled promise drops the retry for every such task. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const historySpy = vi + .spyOn(mockProvider, "updateTaskHistory") + .mockRejectedValueOnce(new Error("history stage unavailable")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Bracket access for the private fields: wait for the async api-config + // initialization to settle, then drop the name - the shape of a task loaded from a + // history entry saved before the api config was recorded. + await task["taskApiConfigReady"] + task["_taskApiConfigName"] = undefined + expect(task["taskApiConfigReadySettled"]).toBe(true) + + await expect(getTaskTestAccess(task).saveClineMessages()).resolves.toBe(true) + expect(historySpy).toHaveBeenCalledTimes(1) + + await task.dispose() + + expect(historySpy).toHaveBeenCalledTimes(2) + historySpy.mockRestore() + }) + + it("does not block teardown when a pending metadata repair has an unsettled api-config init", async () => { // persistTaskMetadata() awaits taskApiConfigReady. For a task whose async api-config // initialization never settles, an unconditional dispose-time retry would hang the - // teardown, so the retry is skipped when the config never initialized. + // teardown, so the retry is skipped until that promise settles. const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") fsReal.mkdirSync(taskDir, { recursive: true }) const task = new Task({ diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 13f6e914e8..fef7e7f64a 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -258,23 +258,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return true } - /** - * Teardown for the presenter's missing-nativeArgs guard. A finalized write_to_file - * block whose streamed JSON never parsed never reaches handle(): the presenter emits - * the tool_result and breaks, so neither execute()'s finally nor - * onParameterParseFailure() runs. Without this the per-task stream state (map entry, - * TaskAborted listener, and a diff view that streaming may have opened with unapproved - * partial content) outlives the call and leaks into the next API request: a stale path - * makes the next write's first delta look stabilized, and a retained streamFailed flag - * suppresses that write's preview. - * - * Silent for the malformed call itself: the guard has already pushed the tool_result - * the provider waits for, so reporting the call again would double-report it. A FAILED - * ROLLBACK is the exception - the editor may still hold content this task never - * approved, and that hazard has to be visible in the chat exactly as the parse-failure - * and failed-stream teardowns report it. - */ - /** * Release the diff view of an abandoned stream. A modify edit restores the original * content, which is safe. A new-file edit must NOT go through revertChanges(): its @@ -294,6 +277,23 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return false } } + + /** + * Teardown for the presenter's missing-nativeArgs guard. A finalized write_to_file + * block whose streamed JSON never parsed never reaches handle(): the presenter emits + * the tool_result and breaks, so neither execute()'s finally nor + * onParameterParseFailure() runs. Without this the per-task stream state (map entry, + * TaskAborted listener, and a diff view that streaming may have opened with unapproved + * partial content) outlives the call and leaks into the next API request: a stale path + * makes the next write's first delta look stabilized, and a retained streamFailed flag + * suppresses that write's preview. + * + * Silent for the malformed call itself: the guard has already pushed the tool_result + * the provider waits for, so reporting the call again would double-report it. A FAILED + * ROLLBACK is the exception - the editor may still hold content this task never + * approved, and that hazard has to be visible in the chat exactly as the parse-failure + * and failed-stream teardowns report it. + */ async teardownAbandonedStream(task: Task): Promise { if (!this.taskPartialStreamState.has(this.getPartialStreamFailureKey(task))) { return @@ -634,6 +634,18 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { false, ) } catch (error) { + // A cancellation that lands while open() or update() is in flight runs the + // TaskAborted teardown - which releases this task's stream state, reverts or + // closes this very diff view, and reports the failure itself - and it can also + // reject the call in flight. Marking the already-released state failed, finalizing + // the ask, or running the failed-stream cleanup a second time would resurrect UI + // and roll back twice for a task the user cancelled, so the teardown owns the + // outcome here. + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + console.error(`Error streaming write_to_file diff view:`, error) + return + } + // Opening or updating the diff view can throw on filesystem errors // (EACCES/EROFS on read-only paths). Finalize the partial tool message // so the UI spinner doesn't get stuck and reset the diff view. Do NOT diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index cdbe434fe6..45a5cf0fda 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -577,6 +577,35 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) + it("does not finalize the ask or roll back twice when open() rejects after a cancellation", async () => { + // A cancellation during open() releases the stream state through the TaskAborted + // teardown (which also reverts or closes this diff view) AND rejects the call in + // flight. The catch used to mark the released state failed, finalize the ask and run + // the failed-stream cleanup again - a second rollback and a fresh ask row for a task + // the user already cancelled. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + mockCline.diffViewProvider.open.mockClear() + mockCline.diffViewProvider.update.mockClear() + mockCline.diffViewProvider.revertChanges.mockClear() + mockCline.diffViewProvider.discardUnapprovedStream.mockClear() + mockCline.ask.mockClear() + mockCline.diffViewProvider.open.mockImplementationOnce(async () => { + writeToFileTool.clearTaskState(mockCline) + throw new Error("EACCES: permission denied, open mock-file") + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() + // Only the partial ask this delta issued: no finalize on top of it. + expect(mockCline.ask).toHaveBeenCalledTimes(1) + // The teardown that already ran owns the rollback: no second one from the catch. + expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) it("does nothing when the task has no stream state to release", async () => { await writeToFileTool.teardownAbandonedStream(mockCline) expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() @@ -1446,6 +1475,10 @@ describe("writeToFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + // With fileExists false the rollback route is discardUnapprovedStream(), so asserting + // only revertChanges here would pass even if the writeApproved guard were dropped and + // the approved edit discarded. Assert the route that can actually fire. + expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() }) @@ -1703,6 +1736,9 @@ describe("writeToFileTool", () => { expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(undefined) expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + // Same reason as the saveChanges case: with fileExists false the rollback route is + // discardUnapprovedStream(), so this is the assertion that can actually fail. + expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() expect(mockCline.didEditFile).toBe(false) } finally { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 20c1d39b0b..7f7546d790 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -33,6 +33,14 @@ export class DiffViewProvider { isEditing = false originalContent: string | undefined private createdDirs: string[] = [] + /** + * Absolute path of the empty placeholder open() created for a new-file edit, or undefined + * when no placeholder is outstanding. reset() does NOT clear relPath, and saveDirectly() + * sets relPath for a file that was written with approval, so relPath alone cannot tell a + * caller that a file on disk belongs to the abandoned edit. Only this field justifies an + * unlink. + */ + private placeholderPath: string | undefined private documentWasOpen = false // Tracks whether the target file's tab was pinned before the diff session. // Closing the tab to open the diff drops VS Code's pin state, so we restore @@ -132,6 +140,10 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") + // From here until the placeholder is removed, THIS edit owns that path. Set after + // the write succeeds, so a failed write does not claim ownership of a file we did + // not create. + this.placeholderPath = absolutePath } // If the file was already open, close it (must happen after showing the @@ -336,6 +348,9 @@ export class DiffViewProvider { } const absolutePath = path.resolve(this.cwd, this.relPath) + // The write below is the approved one: whatever placeholder open() created at this path + // becomes real content, so it is no longer an artifact this edit may remove. + this.placeholderPath = undefined const updatedDocument = this.activeDiffEditor.document const editedContent = updatedDocument.getText() @@ -535,6 +550,10 @@ export class DiffViewProvider { // then the only chance to remove what the abandoned edit left on disk. const createdDirs = this.createdDirs this.createdDirs = [] + // Same reason for the placeholder: an await below may reject and the caller then runs + // reset(), which drops this field too. + const placeholderPath = this.placeholderPath + this.placeholderPath = undefined let editorFailure: unknown // open() creates the directories and the empty placeholder BEFORE it assigns @@ -571,13 +590,18 @@ export class DiffViewProvider { let cleanupFailure: unknown try { - await fs.unlink(absolutePath).catch((error: unknown) => { - // open() creates the placeholder; if it is already gone there is nothing - // left to remove and the discard did its job. - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { - throw error - } - }) + // Only a placeholder THIS edit created may be removed. relPath survives reset(), and + // saveDirectly() sets it for a file that was written with approval, so unlinking + // absolutePath unconditionally can delete content the user approved. + if (placeholderPath) { + await fs.unlink(placeholderPath).catch((error: unknown) => { + // open() creates the placeholder; if it is already gone there is nothing + // left to remove and the discard did its job. + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error + } + }) + } // Remove only the directories this edit created, in reverse order. for (let i = createdDirs.length - 1; i >= 0; i--) { @@ -624,6 +648,7 @@ export class DiffViewProvider { // opened tab before deleting it from disk. await this.closeFileTab(absolutePath) await fs.unlink(absolutePath) + this.placeholderPath = undefined // Remove only the directories we created, in reverse order. for (let i = this.createdDirs.length - 1; i >= 0; i--) { @@ -1200,6 +1225,7 @@ export class DiffViewProvider { this.isEditing = false this.originalContent = undefined this.createdDirs = [] + this.placeholderPath = undefined this.documentWasOpen = false this.documentWasPinned = false this.activeDiffEditor = undefined diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index b884ce9121..2e69b09ace 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1871,6 +1871,8 @@ describe("DiffViewProvider", () => { activeDiffEditor: { document }, editType: "create", createdDirs, + // open() records the placeholder it wrote; the discard may only delete what it owns. + placeholderPath: `${mockCwd}/mock-target-file.ts`, closeAllDiffViews: vi.fn(async () => { callOrder.push("closeDiffViews") }), @@ -1969,6 +1971,9 @@ describe("DiffViewProvider", () => { activeDiffEditor: undefined, editType: "create", createdDirs: [`${mockCwd}/mock-dir`], + // open() had already written the placeholder before openDiffEditor() rejected, + // so this edit owns the path. + placeholderPath: `${mockCwd}/mock-target-file.ts`, }) await diffViewProvider.discardUnapprovedStream() @@ -2038,6 +2043,32 @@ describe("DiffViewProvider", () => { await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") }) + it("leaves a file this edit never created a placeholder for on disk", async () => { + // reset() does not clear relPath, and saveDirectly() sets it for an approved write. + // Without ownership tracking the discard would unlink that approved file: an approved + // write_to_file to A, then a new write_to_file whose block exits through a rollback + // path before open() ever ran, still sees relPath === A. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + Object.assign(diffViewProvider, { + relPath: "previously-approved-file.ts", + activeDiffEditor: { document }, + editType: undefined, + createdDirs: [], + placeholderPath: undefined, + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() + + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + // The buffer is still blanked and the tab closed: the unapproved content is gone + // without touching a file the user approved. + expect(mockWorkspaceEdit.replace).toHaveBeenCalledTimes(1) + expect(mockWorkspaceEdit.replace.mock.calls[0][2]).toBe("") + }) + it("does nothing when no abandoned view is open", async () => { Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) From d9843bd30c50e203e83da4f18cc2ca1f1ff75ba1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 04:15:48 +0800 Subject: [PATCH 22/29] fix(task): run the dispose-time metadata retry after the synchronous teardown The retry added in 3a0065091 sat at the top of disposeOnce(), so it became the first await and delayed three steps that used to run before any yield: the abort flag, cancelCurrentRequest(), and the diffReversionPromise assignment. That breaks abortTaskOnce(), which does void this.dispose() and then awaits diffReversionPromise - it would observe the initial resolved placeholder and let the final saveClineMessages() run before the workspace revert; it lets an abandoned stream reach its finally while the retry is in flight, so the isStreaming && isEditing check below is false and the diff document stays dirty with unapproved content; and it removes the synchronous abort flag that direct dispose() callers (cleanupFailedHistoryTask, drainTaskDisposal) rely on. The retry now runs last, immediately before await pendingCleanup, so the synchronous prefix is restored. It also loses its try/catch: persistTaskMetadata() already reports its own failure and returns false, so the outer catch was unreachable - which is also the mutation-diff NoCoverage advisory at the old line. Test: `finishes the synchronous teardown before the dispose-time metadata retry` - a task-history write that never settles, isStreaming and diffViewProvider.isEditing set, then dispose(); after one macrotask yield the abort flag is true and revertChanges has run, and the history write is still pending. Negative control: moving the retry back to the top -> exactly 1 failed; restored -> 4 passed. Also adds the public-method coverage for the placeholder ownership lifecycle: `removes the placeholder that a new-file open() wrote, driven through the public methods` (real open() with editType create, then discardUnapprovedStream() unlinks the path open() wrote) and `does not remove the file once saveChanges() has approved the content` (saveChanges() drops the claim, a later discard leaves the approved file alone). Negative controls: open() no longer recording placeholderPath -> 1 failed; saveChanges() no longer clearing it -> 1 failed. Verification: core/task 690 passed, integrations/editor 90 passed (plus the known pre-existing local-only saveChanges failure), tsc --noEmit 0 errors, eslint --prune-suppressions --max-warnings=0 clean on all four touched files with src/eslint-suppressions.json unchanged. --- src/core/task/Task.ts | 28 ++++--- src/core/task/__tests__/Task.spec.ts | 44 +++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 76 +++++++++++++++++++ 3 files changed, 133 insertions(+), 15 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9ffff7f5a5..b96676f1a8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3445,21 +3445,6 @@ export class Task extends EventEmitter implements TaskLike { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() - // A save whose metadata / task-history stage failed leaves the history entry behind - // the messages that are already on disk. Give that stage one awaited chance to catch - // up before the task stops serving, so shutdown does not persist a stale history item. - // Skipped while taskApiConfigReady has not settled: persistTaskMetadata() awaits - // it, and an initialization that never settles would block teardown. A task that merely - // has no stored api config name (a history entry saved before it was recorded) HAS - // settled, and still gets its retry. - if (this.pendingTaskMetadataRepair && this.taskApiConfigReadySettled) { - try { - await this.persistTaskMetadata() - } catch (error) { - console.error("Failed to retry task metadata during dispose:", error) - } - } - // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. @@ -3549,6 +3534,19 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error reverting diff changes:", error) } + // A save whose metadata / task-history stage failed leaves the history entry + // behind the messages that are already on disk. Give that stage one awaited chance to + // catch up before the task stops serving. Deliberately last: everything synchronous above + // has to happen before the first yield, because abortTaskOnce() awaits diffReversionPromise + // right after void dispose(), an abandoned stream can reach its finally while this await is + // in flight and leave the revert decision below false, and direct dispose() callers rely on + // the abort flag being set synchronously. Skipped while taskApiConfigReady has not settled - + // persistTaskMetadata() awaits it and teardown must not block on it. It reports its own + // failure and returns false, so there is nothing to catch here. + if (this.pendingTaskMetadataRepair && this.taskApiConfigReadySettled) { + await this.persistTaskMetadata() + } + await pendingCleanup await this.diffReversionPromise } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 4c8c708445..eb216bfdc4 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6299,6 +6299,50 @@ describe("Cline", () => { historySpy.mockRestore() }) + it("finishes the synchronous teardown before the dispose-time metadata retry", async () => { + // The retry has to sit after disposeOnce()'s synchronous section, not in front of it: + // abortTaskOnce() awaits diffReversionPromise right after void dispose(), an abandoned + // stream can reach its finally while the retry is in flight and leave the revert decision + // below false, and direct dispose() callers rely on the abort flag being set + // synchronously. A task-history write that never settles turns any of those three + // regressions into an observable hang. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + let releaseHistory: (() => void) | undefined + const historyGate = new Promise((resolve) => { + releaseHistory = resolve + }) + const historySpy = vi.spyOn(mockProvider, "updateTaskHistory").mockImplementation(() => historyGate.then(() => [])) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + // Let the async api-config initialization settle first: the retry is deliberately + // skipped while it is pending, which would hide the ordering under test. + await task["taskApiConfigReady"] + task["pendingTaskMetadataRepair"] = true + task.isStreaming = true + task.diffViewProvider.isEditing = true + const revertSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockResolvedValue(undefined) + + const disposing = task.dispose() + await new Promise((resolve) => setImmediate(resolve)) + + // The teardown completed without waiting for the task-history write. + expect(task.abort).toBe(true) + expect(revertSpy).toHaveBeenCalledTimes(1) + + releaseHistory?.() + await disposing + + expect(historySpy).toHaveBeenCalledTimes(1) + historySpy.mockRestore() + revertSpy.mockRestore() + }) + it("does not block teardown when a pending metadata repair has an unsettled api-config init", async () => { // persistTaskMetadata() awaits taskApiConfigReady. For a task whose async api-config // initialization never settles, an unconditional dispose-time retry would hang the diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 2e69b09ace..7670037c27 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2069,6 +2069,82 @@ describe("DiffViewProvider", () => { expect(mockWorkspaceEdit.replace.mock.calls[0][2]).toBe("") }) + it("removes the placeholder that a new-file open() wrote, driven through the public methods", async () => { + // The ownership lifecycle lives at the public-method layer, so exercise it there: + // open() writes the empty placeholder and records it, and the discard of an abandoned + // create removes exactly that path. + const relPath = "owned-placeholder.ts" + const fsPath = `${mockCwd}/${relPath}` + const mockEditor = { + document: { + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + // Structural double for the mocked editor: the mock only implements the members + // open() touches, so it is routed through unknown rather than any. + const editor = mockEditor as unknown as vscode.TextEditor + vi.mocked(vscode.window).visibleTextEditors = [editor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(editor) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath, scheme: "file" } } as vscode.TextDocument), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.window.onDidChangeTextEditorVisibleRanges).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + diffViewProvider.editType = "create" + + await diffViewProvider.open(relPath) + + // open() created the placeholder, and this edit now owns it. + expect(fs.writeFile).toHaveBeenCalledWith(fsPath, "") + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).toHaveBeenCalledWith(fsPath) + }) + + it("does not remove the file once saveChanges() has approved the content", async () => { + // saveChanges() turns the placeholder into approved content and drops this edit's + // claim on it, so a later abandoned cleanup must leave the approved file on disk. + const relPath = "approved-by-save.ts" + const fsPath = `${mockCwd}/${relPath}` + Object.assign(diffViewProvider, { + relPath, + newContent: "approved content", + editType: "create", + createdDirs: [], + placeholderPath: fsPath, + preDiagnostics: [], + activeDiffEditor: { + document: { + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue("approved content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + }, + closeAllDiffViews: vi.fn().mockResolvedValue(undefined), + closeFileTab: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(undefined as never) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await diffViewProvider.saveChanges(false, 0) + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).not.toHaveBeenCalled() + }) + it("does nothing when no abandoned view is open", async () => { Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) From aa959bf12c1e3ad0724b05622e08ddd0b5197819 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 06:01:22 +0800 Subject: [PATCH 23/29] test(editor): cover the ENOENT tolerance for every directory the discard created The already-deleted placeholder test only ever carried one created directory, so its name promised a plural it never exercised and a loop that stopped after the first entry would still have passed. It now creates two directories, keys the injected ENOENT on the path rather than on call order, and asserts both paths were attempted. Negative controls: dropping the ENOENT tolerance in the rmdir catch -> 1 failed; truncating the reverse loop to the first entry -> 3 failed. Restored -> 13 passed. --- .../editor/__tests__/DiffViewProvider.spec.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 7670037c27..e6557b7177 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2019,15 +2019,31 @@ describe("DiffViewProvider", () => { it("tolerates an already-deleted placeholder and directories (ENOENT) without throwing", async () => { const callOrder: string[] = [] const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) + // Two created directories: the tolerance has to hold for every one of them, so the + // rejection is keyed on the path rather than on which call comes first. + const dirA = `${mockCwd}/mock-dir-a` + const dirB = `${mockCwd}/nested/mock-dir-b` + openAbandonedView(document, [dirA, dirB], callOrder) vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) - vi.mocked(fs.rmdir).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + // A single unlink call in this flow, so the placeholder stays Once-based. + vi.mocked(fs.unlink).mockRejectedValueOnce(enoent) + vi.mocked(fs.rmdir).mockImplementation(async (dir) => { + if (dir === dirA || dir === dirB) { + throw enoent + } + }) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() + // Both directories were attempted; the assertion is on the paths, not the order. + expect(fs.rmdir).toHaveBeenCalledWith(dirA) + expect(fs.rmdir).toHaveBeenCalledWith(dirB) expect(errorSpy).not.toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", expect.anything()) + + // beforeEach only clears call data, not implementations, so put the default back. + vi.mocked(fs.rmdir).mockResolvedValue(undefined) }) it("reports the editor failure rather than the cleanup failure when both happen", async () => { From 4f02647a2e46d1ea3873927d1685a8531ccd6cb3 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 13:08:45 +0800 Subject: [PATCH 24/29] fix(write-to-file): release the partial stream state when the preview is suppressed handlePartial() registers this task's partial-stream entry (and its TaskAborted listener) before it checks the prevent-focus-disruption experiment. With the experiment enabled the delta then returns without ever showing a preview, and nothing else releases that entry: the listener stays attached for the rest of the task's life, and a streamFailed mark armed by an earlier delta keeps suppressing this task's later diff previews. Release the bookkeeping on that return - the last exit of the tool call that skipped a teardown. Every exit of execute() is already covered here by the finally block. Same root cause as the Lifecycle Resource Cleanup row on the sibling units of #1066; the sibling branches get the same release in their own PRs. --- src/core/tools/WriteToFileTool.ts | 5 +++++ src/core/tools/__tests__/writeToFileTool.spec.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index fef7e7f64a..b09d6de523 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -580,6 +580,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ) if (isPreventFocusDisruptionEnabled) { + // The preview is suppressed for this stream: release the entry registered above so the + // abort listener and any failure mark do not outlive a delta that never shows a diff + // view and never reaches execute()'s teardown. + super.resetPartialState() + this.resetTaskPartialState(task) return } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 45a5cf0fda..1994c1f5a4 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -1761,6 +1761,22 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() }) + it("releases the per-task stream state when prevent-focus-disruption skips the partial preview", async () => { + // Delta 1 only pins the path, so the entry is still live afterwards (the stream is in + // flight). Delta 2 reaches the experiment check and returns without ever showing a + // preview: nothing else would release the entry or detach the TaskAborted listener. + enablePreventFocusDisruption() + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.ask).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, expect.any(Function)) + }) + it("clears the provider state when prevent-focus approval is denied", async () => { // The prevent-focus branch stamps editType/originalContent on the provider before // asking. On denial nothing was approved and no diff document was opened, so the From 1e6828073e9c071cd54e86d5cd958a22e256aefa Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:50:26 +0800 Subject: [PATCH 25/29] fix(task,write-to-file): repair metadata when the config name is known and guard pre-stream setup Addresses the two #1066/U7 checklist rows on the at-head assessment (a244ef55b): - Persistence Integrity (error): the dispose-time metadata retry ran only when taskApiConfigReadySettled was true. persistTaskMetadata() awaits that promise only while _taskApiConfigName is still undefined, so once the name is known (handoff, resumed history item, setTaskApiConfigName) the retry cannot block teardown and skipping it leaves the history entry behind messages already on disk. The condition is now pendingTaskMetadataRepair && (taskApiConfigReadySettled || _taskApiConfigName !== undefined). - Lifecycle Resource Cleanup (warning): handlePartial() registers the per-task stream state and its TaskAborted listener, then awaits provider.getState(), the filesystem probe and the partial ask with no cleanup path. Those awaits are now inside a boundary that releases this task's state and rethrows, so BaseTool.handle() still reports the error once. partialMessage is hoisted above the boundary because the diff-view catch finalizes the same ask. Red first: both new tests failed before the fixes. Negative controls: condition back to settled-only -> exactly the metadata test red; teardown call removed from the new catch -> exactly the handlePartial test red; rethrow removed -> exactly the same test red. Verification: Task.spec 177, writeToFileTool 68/5s, partial-state-cleanup, DiffViewProvider - 384 passed; tsc 62 (baseline, none in touched files); eslint 0/0; eslint-suppressions unchanged. --- src/core/task/Task.ts | 20 ++-- src/core/task/__tests__/Task.spec.ts | 34 ++++++ src/core/tools/WriteToFileTool.ts | 106 +++++++++++------- .../tools/__tests__/writeToFileTool.spec.ts | 26 +++++ 4 files changed, 135 insertions(+), 51 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1b2174f398..2f9a701652 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -380,11 +380,12 @@ export class Task extends EventEmitter implements TaskLike { */ private taskApiConfigReady: Promise /** - * Whether taskApiConfigReady has settled. The dispose-time metadata retry needs this - * rather than a check on _taskApiConfigName: a legacy history item legitimately has no - * stored api config while its initialization completed, and that task still needs the - * retry. Only a promise that never settles may suppress it, because - * persistTaskMetadata() awaits it and teardown must not block on it. + * Whether taskApiConfigReady has settled. The dispose-time metadata retry needs it only while + * _taskApiConfigName is still undefined: persistTaskMetadata() awaits the promise in that case, + * and teardown must not block on it. A legacy history item legitimately has no stored api + * config while its initialization completed, and that task still needs the retry; once the + * name IS known, persistTaskMetadata() skips the await, so an unsettled promise must not + * suppress the repair either. */ private taskApiConfigReadySettled = false @@ -3585,9 +3586,12 @@ export class Task extends EventEmitter implements TaskLike { // right after void dispose(), an abandoned stream can reach its finally while this await is // in flight and leave the revert decision below false, and direct dispose() callers rely on // the abort flag being set synchronously. Skipped while taskApiConfigReady has not settled - - // persistTaskMetadata() awaits it and teardown must not block on it. It reports its own - // failure and returns false, so there is nothing to catch here. - if (this.pendingTaskMetadataRepair && this.taskApiConfigReadySettled) { + // the abort flag being set synchronously. Skipped only when the retry could block: while + // taskApiConfigReady has not settled AND _taskApiConfigName is still undefined, + // persistTaskMetadata() awaits that promise and teardown must not wait on it. Once the name + // is known the await is skipped inside persistTaskMetadata(), so the repair is safe to run + // and skipping it would leave the history entry stale for a task that could repair it. + if (this.pendingTaskMetadataRepair && (this.taskApiConfigReadySettled || this._taskApiConfigName !== undefined)) { await this.persistTaskMetadata() } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 4622274848..e9ce69c819 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6809,6 +6809,40 @@ describe("Cline", () => { expect(outcome).toBe("disposed") }) + it("runs the dispose-time metadata retry when the api-config name is already known even if init never settles", async () => { + // persistTaskMetadata() only awaits taskApiConfigReady while _taskApiConfigName is still + // undefined. Once the name is known (a handoff, a resumed history item, or an explicit + // setTaskApiConfigName), the retry cannot block teardown - so skipping it because the + // promise has not settled leaves the history entry stale for a task that was perfectly + // able to repair it. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const historySpy = vi.spyOn(mockProvider, "updateTaskHistory").mockResolvedValue([]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Bracket access for the private fields: the pathological combination - a name that is + // already resolved while the initialization promise never settles - is not reachable + // through the public API. + task["_taskApiConfigName"] = "my-profile" + task["taskApiConfigReady"] = new Promise(() => {}) + task["taskApiConfigReadySettled"] = false + task["pendingTaskMetadataRepair"] = true + + const outcome = await Promise.race([ + task.dispose().then(() => "disposed"), + new Promise((resolve) => setTimeout(() => resolve("timed out"), 2000)), + ]) + + expect(outcome).toBe("disposed") + expect(historySpy).toHaveBeenCalledTimes(1) + historySpy.mockRestore() + }) + it("finalizePartialToolAsk skips the webview update when the message write itself fails", async () => { // Complements the later-stage-failure test above by failing the first save // stage: with the real task directory removed, safeWriteJson's fs.access diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 411265635b..7462be0f75 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -564,60 +564,76 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - const provider = task.providerRef.deref() - const state = await provider?.getState() + // Hoisted out of the guarded setup: the diff-view catch further down finalizes the + // same partial ask, so the message has to stay in scope once the try block ends. + let partialMessage: string | undefined - // Cancelled while provider state was in flight: the teardown already - // released this task's stream state. - if (!this.isPartialStreamStillLive(task, partialStreamState)) { - return - } - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) - - if (isPreventFocusDisruptionEnabled) { - // The preview is suppressed for this stream: release the entry registered above so the - // abort listener and any failure mark do not outlive a delta that never shows a diff - // view and never reaches execute()'s teardown. - super.resetPartialState() - this.resetTaskPartialState(task) - return - } - - // relPath is guaranteed non-null after hasPathStabilized - let fileExists: boolean - const absolutePath = path.resolve(task.cwd, relPath!) + try { + // Everything from here to the diff view is setup that can fail before + // execute() ever runs; the catch below owns the teardown for that window. + const provider = task.providerRef.deref() + const state = await provider?.getState() - if (task.diffViewProvider.editType !== undefined) { - fileExists = task.diffViewProvider.editType === "modify" - } else { - fileExists = await fileExistsAtPath(absolutePath) + // Cancelled while provider state was in flight: the teardown already + // released this task's stream state. if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } - task.diffViewProvider.editType = fileExists ? "modify" : "create" - } + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + if (isPreventFocusDisruptionEnabled) { + // The preview is suppressed for this stream: release the entry registered above so the + // abort listener and any failure mark do not outlive a delta that never shows a diff + // view and never reaches execute()'s teardown. + super.resetPartialState() + this.resetTaskPartialState(task) + return + } - const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // relPath is guaranteed non-null after hasPathStabilized + let fileExists: boolean + const absolutePath = path.resolve(task.cwd, relPath!) - const sharedMessageProps: ClineSayTool = { - tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(task.cwd, relPath!), - content: newContent || "", - isOutsideWorkspace, - isProtected: isWriteProtected, - } + if (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + fileExists = await fileExistsAtPath(absolutePath) + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + return + } + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } + + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) - const partialMessage = JSON.stringify(sharedMessageProps) - await task.ask("tool", partialMessage, block.partial).catch(() => {}) + const sharedMessageProps: ClineSayTool = { + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(task.cwd, relPath!), + content: newContent || "", + isOutsideWorkspace, + isProtected: isWriteProtected, + } + + partialMessage = JSON.stringify(sharedMessageProps) + await task.ask("tool", partialMessage, block.partial).catch(() => {}) - if (!this.isPartialStreamStillLive(task, partialStreamState)) { + if (!this.isPartialStreamStillLive(task, partialStreamState)) { return } + } catch (error) { + // Unexpected failure in the pre-streaming setup (provider state, the filesystem probe, the + // partial ask): this delta never reaches the diff view or execute(), so nothing else + // releases what the registration above acquired. Drop this task's entry and its + // TaskAborted listener, then rethrow - BaseTool.handle() still reports the error once. + this.resetTaskPartialState(task) + throw error + } + if (newContent) { try { if (!task.diffViewProvider.isEditing) { @@ -664,7 +680,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // onParameterParseFailure() can report this failure to the user. partialStreamState.streamFailed = true partialStreamState.streamError = error instanceof Error ? error : new Error(String(error)) - await this.finalizePartialToolAskAfterFailure(task, partialMessage) + // The ask only exists once the setup above assigned its message; before that there + // is nothing to finalize. + if (partialMessage !== undefined) { + await this.finalizePartialToolAskAfterFailure(task, partialMessage) + } // The write was never approved: restore the document so a user save cannot // persist the failed streamed content (reset() alone leaves it dirty), and // surface the hazard if that restore itself failed. The stream error is diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 1994c1f5a4..d22fe1e41c 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -1017,6 +1017,32 @@ describe("writeToFileTool", () => { consoleErrorSpy.mockRestore() } }) + + it("releases the per-task stream state when provider state rejects during a partial delta", async () => { + // handlePartial() registers the entry and the TaskAborted listener, then awaits + // provider.getState(). A rejection there never reaches the diff view or execute(), so + // nothing else releases what the registration acquired: the entry and its listener would + // survive for the task's lifetime. The error still has to surface (BaseTool reports it). + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockRejectedValue(new Error("provider state unavailable")), + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + const abortListener = mockCline.once.mock.calls.find( + ([event]: unknown[]) => event === RooCodeEventName.TaskAborted, + )?.[1] + expect(abortListener).toBeInstanceOf(Function) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + expect(mockHandleError).toHaveBeenCalledWith( + "handling partial write_to_file", + expect.objectContaining({ message: "provider state unavailable" }), + ) + }) }) describe("path stabilization predicate", () => { From f8f7ce19a459df23da0504b17c21e083fedfe773 Mon Sep 17 00:00:00 2001 From: eason liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:58:15 +0800 Subject: [PATCH 26/29] fix(write-to-file): never save an unapproved stream when releasing its diff view CodeRabbit Security Boundaries on #1928: the rooignore-denial cleanup released the diff view through revertChanges(), which SAVES. For a modify that writes the restored original content to a path the policy had just refused; for a create it persists the dirty partial buffer before deleting the file, so a failed delete leaves unapproved bytes on disk. discardUnapprovedStream() now covers both edit types: a create buffer is emptied before anything can reach the file, a modify buffer is restored to the content already on disk in memory only, and the target file is never written. The restore is checked - if applyEdit does not apply, the save is skipped (saving then would persist exactly the content being discarded) and the failure surfaces to the caller as the rollback hazard it is. CodeRabbit Lifecycle Resource Cleanup on #1928: the TaskAborted teardown can only release the diff view that existed when it ran. When open() settles AFTER the release it publishes a view nobody owns - for a create, the placeholder plus the directories open() wrote - and execute() never runs for a cancelled stream. handlePartial() now runs an idempotent abandoned-view cleanup at both settle points around open(), and Task disposal releases a cancelled create the same way instead of saving it. CodeRabbit Persistence Integrity on #1928 (first half): persistTaskMetadata() read the provider through an optional chain, so a released provider turned the two history writes into no-ops that reported success and CLEARED pendingTaskMetadataRepair. A missing provider is now treated as the failed stage it always was and the flag stays set for the disposeOnce() retry. The in-flight tracking half is filed on the plan issue as a follow-up with acceptance criteria. Negative controls (Buffer snapshot + sha256 verified after every mutant): - restore always empty / modify saved again / applyEdit result ignored -> exactly the matching DiffViewProvider test red (1 each). - settle-site cleanup removed -> 1 red; idempotency guard removed -> 2 red; modify routed back to revertChanges -> the 2 routing tests red. - provider guard removed -> 1 red; disposal always reverts -> 1 red. Local: DiffViewProvider 86 passed; writeToFileTool + partial-state-cleanup 82 passed (5 skipped); Task.spec 180 passed; Task.dispose 14 passed; broad sweep (core/tools, integrations/editor, core/task, services/mcp) 72 files / 1544 passed; tsc --noEmit 0 with the local @roo-code/types paths override; eslint . --ext=ts --max-warnings=0 exit 0; eslint-suppressions.json untouched. --- src/core/task/Task.ts | 25 ++++- src/core/task/__tests__/Task.dispose.test.ts | 9 ++ src/core/task/__tests__/Task.spec.ts | 102 ++++++++++++++++++ src/core/tools/WriteToFileTool.ts | 62 ++++++++--- ...teToFileTool-partial-state-cleanup.spec.ts | 26 ++++- .../tools/__tests__/writeToFileTool.spec.ts | 75 +++++++++++-- src/integrations/editor/DiffViewProvider.ts | 49 +++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 54 ++++++++++ 8 files changed, 359 insertions(+), 43 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2f9a701652..d332a9b54f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1820,8 +1820,17 @@ export class Task extends EventEmitter implements TaskLike { this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) const provider = this.providerRef.deref() - const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status - await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) + // A released provider makes the two calls below optional-chain no-ops, so without this + // guard the stage reports success while persisting nothing and clears the repair flag - + // the history entry then stays behind the messages that ARE on disk and nothing retries + // it. Treat it as the failed stage it is and leave the flag set for disposeOnce(). + if (!provider) { + this.pendingTaskMetadataRepair = true + console.error("Failed to save task metadata: the task has no provider to persist task history.") + return false + } + const existingStatus = provider.taskHistoryStore.get(this.taskId)?.status + await provider.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) this.pendingTaskMetadataRepair = false return true } catch (error) { @@ -3573,7 +3582,16 @@ export class Task extends EventEmitter implements TaskLike { try { // If we're not streaming then `abortStream` won't be called. if (this.isStreaming && this.diffViewProvider.isEditing) { - this.diffReversionPromise = this.diffViewProvider.revertChanges().catch(console.error) + // A cancelled stream was never approved. For a create, revertChanges() saves the dirty + // buffer - the partial content the model streamed - before deleting the file, so a + // failed delete leaves unapproved bytes on disk; the discard path empties the buffer + // first, so the only bytes that can reach the placeholder are none. A modify still + // reverts, which restores the content already on disk. + const releaseUnapprovedEdit = + this.diffViewProvider.editType === "modify" + ? this.diffViewProvider.revertChanges() + : this.diffViewProvider.discardUnapprovedStream() + this.diffReversionPromise = releaseUnapprovedEdit.catch(console.error) } } catch (error) { console.error("Error reverting diff changes:", error) @@ -3585,7 +3603,6 @@ export class Task extends EventEmitter implements TaskLike { // has to happen before the first yield, because abortTaskOnce() awaits diffReversionPromise // right after void dispose(), an abandoned stream can reach its finally while this await is // in flight and leave the revert decision below false, and direct dispose() callers rely on - // the abort flag being set synchronously. Skipped while taskApiConfigReady has not settled - // the abort flag being set synchronously. Skipped only when the retry could block: while // taskApiConfigReady has not settled AND _taskApiConfigName is still undefined, // persistTaskMetadata() awaits that promise and teardown must not wait on it. Once the name diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 472218fce5..388f1faa77 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -196,8 +196,11 @@ describe("Task dispose method", () => { resolveCleanup = resolve }), ) + // A modify: disposal reverts it. Disposal discards a create instead (see the + // create/modify pair in Task.spec.ts), and these tests are about the reversion promise. task.isStreaming = true task.diffViewProvider.isEditing = true + task.diffViewProvider.editType = "modify" const revertChangesSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockReturnValue( new Promise((resolve) => { resolveReversion = resolve @@ -231,8 +234,11 @@ describe("Task dispose method", () => { const reversion = new Promise((resolve) => { resolveReversion = resolve }) + // A modify: disposal reverts it. Disposal discards a create instead (see the + // create/modify pair in Task.spec.ts), and these tests are about the reversion promise. task.isStreaming = true task.diffViewProvider.isEditing = true + task.diffViewProvider.editType = "modify" const revertChangesSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockReturnValue(reversion) const disposal = task.dispose() @@ -253,8 +259,11 @@ describe("Task dispose method", () => { test("should log rejected diff reversion and continue final abort persistence", async () => { const reversionError = new Error("reversion failed") const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + // A modify: disposal reverts it. Disposal discards a create instead (see the + // create/modify pair in Task.spec.ts), and these tests are about the reversion promise. task.isStreaming = true task.diffViewProvider.isEditing = true + task.diffViewProvider.editType = "modify" vi.spyOn(task.diffViewProvider, "revertChanges").mockRejectedValue(reversionError) const saveMessages = vi.fn().mockResolvedValue(true) Object.defineProperty(task, "saveClineMessages", { value: saveMessages }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index e9ce69c819..e7942cf95d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6738,6 +6738,40 @@ describe("Cline", () => { historySpy.mockRestore() }) + it("treats a released provider as a failed metadata stage instead of a silent success", async () => { + // providerRef.deref() returns undefined once the provider is gone. Without the guard the + // two history calls optional-chain to nothing, persistTaskMetadata() returns true and + // CLEARS pendingTaskMetadataRepair: the history entry then stays behind the messages that + // are already on disk and disposeOnce() has nothing left to retry on. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const historySpy = vi.spyOn(mockProvider, "updateTaskHistory").mockResolvedValue([]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + // Bracket access for the private field: the flag must start clear so the assertion below + // proves the failed stage SET it again rather than merely leaving it alone. + await task["taskApiConfigReady"] + task["pendingTaskMetadataRepair"] = false + Object.defineProperty(task, "providerRef", { + value: { deref: vi.fn().mockReturnValue(undefined) }, + configurable: true, + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const persisted = await task["persistTaskMetadata"]() + + expect(persisted).toBe(false) + expect(historySpy).not.toHaveBeenCalled() + expect(task["pendingTaskMetadataRepair"]).toBe(true) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("no provider to persist task history")) + errorSpy.mockRestore() + historySpy.mockRestore() + }) + it("finishes the synchronous teardown before the dispose-time metadata retry", async () => { // The retry has to sit after disposeOnce()'s synchronous section, not in front of it: // abortTaskOnce() awaits diffReversionPromise right after void dispose(), an abandoned @@ -6765,14 +6799,28 @@ describe("Cline", () => { task["pendingTaskMetadataRepair"] = true task.isStreaming = true task.diffViewProvider.isEditing = true + // A modify: disposal reverts it. The edit type decides which release path disposal takes + // (see the create/modify disposal pair below), and this test is about the ordering. + task.diffViewProvider.editType = "modify" const revertSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockResolvedValue(undefined) const disposing = task.dispose() + // No await before this one: dispose() sets the abort flag synchronously, so a retry moved + // in front of the teardown would park dispose() on the gated task-history write first and + // this would read false. + expect(task.abort).toBe(true) + await new Promise((resolve) => setImmediate(resolve)) // The teardown completed without waiting for the task-history write. expect(task.abort).toBe(true) expect(revertSpy).toHaveBeenCalledTimes(1) + // Bracket access for the private field: abortTaskOnce() awaits this promise right after + // void dispose(), so it has to exist once the teardown has run. + expect(task["diffReversionPromise"]).toBeDefined() + // And the retry has actually started: historySpy is gated, so without this assertion the + // test cannot tell "the teardown ran first" from "the retry never ran at all". + expect(historySpy).toHaveBeenCalledTimes(1) releaseHistory?.() await disposing @@ -6782,6 +6830,60 @@ describe("Cline", () => { revertSpy.mockRestore() }) + it("discards a cancelled create preview instead of saving it through revertChanges", async () => { + // revertChanges() for a create SAVES the dirty buffer - the partial content the model + // streamed and nobody approved - before deleting the file, so a failed delete leaves + // unapproved bytes on disk. A cancelled stream never reached approval, so disposal has to + // take the discard path, which empties the buffer first. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task["taskApiConfigReady"] + task.isStreaming = true + task.diffViewProvider.isEditing = true + task.diffViewProvider.editType = "create" + const revertSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockResolvedValue(undefined) + const discardSpy = vi.spyOn(task.diffViewProvider, "discardUnapprovedStream").mockResolvedValue(undefined) + + await task.dispose() + + expect(discardSpy).toHaveBeenCalledTimes(1) + expect(revertSpy).not.toHaveBeenCalled() + revertSpy.mockRestore() + discardSpy.mockRestore() + }) + + it("still reverts a cancelled modify preview, whose restore is the content already on disk", async () => { + // The pair of the test above: a modify has prior content, and disposal keeps reverting it + // so the editor shows what was on disk before the stream started. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task["taskApiConfigReady"] + task.isStreaming = true + task.diffViewProvider.isEditing = true + task.diffViewProvider.editType = "modify" + const revertSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockResolvedValue(undefined) + const discardSpy = vi.spyOn(task.diffViewProvider, "discardUnapprovedStream").mockResolvedValue(undefined) + + await task.dispose() + + expect(revertSpy).toHaveBeenCalledTimes(1) + expect(discardSpy).not.toHaveBeenCalled() + revertSpy.mockRestore() + discardSpy.mockRestore() + }) + it("does not block teardown when a pending metadata repair has an unsettled api-config init", async () => { // persistTaskMetadata() awaits taskApiConfigReady. For a task whose async api-config // initialization never settles, an unconditional dispose-time retry would hang the diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 7462be0f75..2f5d55ac4d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -162,13 +162,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * state teardown) still runs so the task is not left half-torn-down. */ private async revertDiffChangesBeforeReset(task: Task): Promise { - // Every caller of this restores content the user never approved, so a new-file - // edit must NOT go through revertChanges(): its new-file branch saves the dirty - // buffer - unapproved partial model output - before deleting the file, so a - // failed delete leaves that content on disk (for a .rooignore-denied path that - // is a write the policy forbids). releaseAbandonedDiffView() keeps revertChanges() - // for modify edits, which restores the original content and is safe, and discards - // a new-file buffer without ever persisting it. + // Every caller of this restores content the user never approved, so neither edit + // type may go through revertChanges(): it SAVES. Its create branch saves the dirty + // buffer - unapproved partial model output - before deleting the file, so a failed + // delete leaves that content on disk; its modify branch restores the original + // content and saves it, which for a .rooignore-denied path is a write the policy + // forbids and for any abandoned stream a write the user never approved. return this.releaseAbandonedDiffView(task) } @@ -258,18 +257,17 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } /** - * Release the diff view of an abandoned stream. A modify edit restores the original - * content, which is safe. A new-file edit must NOT go through revertChanges(): its - * new-file branch saves the dirty buffer - unapproved partial model output - before - * deleting the file, so it discards the buffer without persisting it instead. + * Release the diff view of an abandoned, failed, or denied stream without writing to + * the target file. Neither edit type may go through revertChanges(), because that + * method saves: its create branch persists the dirty buffer (unapproved partial model + * output) before deleting the file, and its modify branch writes the restored original + * content back to a file the user never approved - a write the rooignore policy + * forbids outright for a denied path. discardUnapprovedStream() restores the buffer in + * memory only and removes the artifacts this edit created. */ private async releaseAbandonedDiffView(task: Task): Promise { try { - if (task.diffViewProvider.editType === "modify") { - await task.diffViewProvider.revertChanges() - } else { - await task.diffViewProvider.discardUnapprovedStream() - } + await task.diffViewProvider.discardUnapprovedStream() return true } catch (releaseError) { console.error("Error releasing the abandoned write_to_file diff view:", releaseError) @@ -277,6 +275,31 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } } + /** + * Own the diff view that open() publishes AFTER cancellation already released this + * stream. The TaskAborted teardown only knows about the view that existed when it ran; + * a create that completes afterwards leaves its placeholder and the directories open() + * made on disk with no owner, and a modify leaves a dirty preview - nothing else in the + * stream path will touch them, because execute() never runs for a cancelled stream. + * + * Idempotent by construction: the discard is a no-op once reset() has cleared the + * provider (no relPath / activeDiffEditor), so a second settle of the same open() - or + * a later delta that also observes the release - cannot roll back twice or report the + * hazard twice. + */ + private async discardDiffViewOpenedAfterRelease(task: Task): Promise { + if (!task.diffViewProvider.isEditing) { + return + } + const released = await this.releaseAbandonedDiffView(task) + await this.resetDiffViewAfterWrite(task) + if (!released) { + // The buffer could not be restored, so the editor may still show content this task + // never approved; the cancellation itself reports its own outcome, not this hazard. + await this.reportRevertFailure(task) + } + } + /** * Teardown for the presenter's missing-nativeArgs guard. A finalized write_to_file * block whose streamed JSON never parsed never reaches handle(): the presenter emits @@ -643,8 +666,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // Cancellation may land while open() is in flight: its abort handler has // already torn the stream down (and may have reverted or closed this very // diff view), so streaming the partial content into it now would resurrect a - // view for a task that no longer exists. + // view for a task that no longer exists. The view open() just published is now + // this method's responsibility - the teardown ran before it existed. if (!this.isPartialStreamStillLive(task, partialStreamState)) { + await this.discardDiffViewOpenedAfterRelease(task) return } @@ -662,6 +687,9 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // outcome here. if (!this.isPartialStreamStillLive(task, partialStreamState)) { console.error(`Error streaming write_to_file diff view:`, error) + // The teardown ran while this call was in flight, so it could not have released the + // view open() had already published (or left half-published). Discard it here. + await this.discardDiffViewOpenedAfterRelease(task) return } diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts index a32112e661..d52ac3104e 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -188,6 +188,20 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { errorSpy.mockRestore() }) + it("stays silent when the failed-stream cleanup restores the buffer", async () => { + // The hazard report is conditional on the rollback failing. A cleanup that always + // reported "could not be restored" would pass the test above and still spam a false + // warning on every failed stream, so the success path needs its own negative case. + const task = buildTask("failed-stream-cleanup-ok", "inst-12") + const t = task as unknown as CleanupTask + + await writeToFileTool["cleanupFailedPartialStream"](task) + + expect(t.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) + expect(t.diffViewProvider.reset).toHaveBeenCalledTimes(1) + expect(t.say).not.toHaveBeenCalled() + }) + it("logs and continues when finalizing the open partial ask fails", async () => { const task = buildTask("finalize-fails", "inst-5") const t = task as unknown as CleanupTask @@ -215,17 +229,19 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { expect(rolledBack).toBe(true) }) - it("keeps revertChanges for a modify rollback so the original content is restored", async () => { - // A modify edit has real prior content on disk; restoring it through - // revertChanges() is safe and must not be replaced by the discard path. + it("routes a modify rollback through the discard path as well, because revertChanges() saves", async () => { + // A modify edit does have prior content on disk, but revertChanges() restores it by + // WRITING: applyEdit plus document.save(). Every caller of this helper restores content + // the user never approved, and for a .rooignore-denied path that write is one the policy + // forbids outright - so both edit types take the in-memory discard. const task = buildTask("modify-rollback", "inst-11") const t = task as unknown as CleanupTask t.diffViewProvider.editType = "modify" const rolledBack = await writeToFileTool["revertDiffChangesBeforeReset"](task) - expect(t.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) - expect(t.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() + expect(t.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) + expect(t.diffViewProvider.revertChanges).not.toHaveBeenCalled() expect(rolledBack).toBe(true) }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index d22fe1e41c..53f81d8a72 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -520,18 +520,20 @@ describe("writeToFileTool", () => { ) }) - it("restores the original content for a modify edit instead of discarding it", async () => { - // A modify edit has pre-existing content on disk, so restoring it is both safe - // and correct; only the new-file case must avoid the save. + it("discards a modify teardown too, because revertChanges() would save it", async () => { + // revertChanges() restores the original content and SAVES it. For a stream the user + // never approved that is a write they never asked for - and for a rooignore-denied + // path a write the policy forbids - so both edit types take the in-memory discard. await executeWriteFileTool({}, { fileExists: false, isPartial: true }) await executeWriteFileTool({}, { fileExists: false, isPartial: true }) mockCline.diffViewProvider.editType = "modify" mockCline.diffViewProvider.discardUnapprovedStream = vi.fn().mockResolvedValue(undefined) + mockCline.diffViewProvider.revertChanges.mockClear() await writeToFileTool.teardownAbandonedStream(mockCline) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) - expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) @@ -577,6 +579,63 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) + it("discards the diff view that open() publishes after the stream state was released", async () => { + // The TaskAborted teardown can only release the view that existed when it ran. When open() + // completes AFTER the release it publishes a view nobody owns any more: for a create that + // is the placeholder plus the directories open() wrote to disk, and execute() never runs + // for a cancelled stream, so nothing else would ever remove them. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + mockCline.diffViewProvider.open.mockClear() + mockCline.diffViewProvider.update.mockClear() + mockCline.diffViewProvider.discardUnapprovedStream.mockClear() + mockCline.diffViewProvider.revertChanges.mockClear() + mockCline.diffViewProvider.reset.mockClear() + // open() is only called when no view is open yet, so the flag starts clear and open() + // itself publishes the view - the real ordering, where the cancellation lands inside the + // window open() is in flight. + mockCline.diffViewProvider.isEditing = false + mockCline.diffViewProvider.open.mockImplementationOnce(async () => { + mockCline.diffViewProvider.isEditing = true + writeToFileTool.clearTaskState(mockCline) + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() + // The delta that lost the race owns the view open() just published, and releasing it must + // never go through revertChanges(): that saves. + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(1) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + it("does not roll back twice when a released stream is observed a second time", async () => { + // Idempotency: the discard is a no-op once reset() has cleared the provider, so a second + // observation of the same release cannot roll back again or report a hazard that never + // happened. reset() is what clears isEditing, which is the guard's only input. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + mockCline.diffViewProvider.open.mockClear() + mockCline.diffViewProvider.discardUnapprovedStream.mockClear() + mockCline.diffViewProvider.reset.mockClear() + mockCline.say.mockClear() + mockCline.diffViewProvider.isEditing = false + mockCline.diffViewProvider.reset.mockImplementation(async () => { + mockCline.diffViewProvider.isEditing = false + }) + mockCline.diffViewProvider.open.mockImplementationOnce(async () => { + mockCline.diffViewProvider.isEditing = true + writeToFileTool.clearTaskState(mockCline) + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + // A later settle observes the same release with nothing left to release. + await writeToFileTool["discardDiffViewOpenedAfterRelease"](mockCline) + + expect(mockCline.diffViewProvider.discardUnapprovedStream).toHaveBeenCalledTimes(1) + expect(mockCline.say).not.toHaveBeenCalled() + }) it("does not finalize the ask or roll back twice when open() rejects after a cancellation", async () => { // A cancellation during open() releases the stream state through the TaskAborted // teardown (which also reverts or closes this diff view) AND rejects the call in @@ -591,6 +650,7 @@ describe("writeToFileTool", () => { mockCline.diffViewProvider.revertChanges.mockClear() mockCline.diffViewProvider.discardUnapprovedStream.mockClear() mockCline.ask.mockClear() + mockCline.finalizePartialToolAsk.mockClear() mockCline.diffViewProvider.open.mockImplementationOnce(async () => { writeToFileTool.clearTaskState(mockCline) throw new Error("EACCES: permission denied, open mock-file") @@ -599,8 +659,11 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() - // Only the partial ask this delta issued: no finalize on top of it. + // Only the partial ask this delta issued: no finalize on top of it. Asserted on the + // finalize mock itself - finalizePartialToolAskAfterFailure() calls finalizePartialToolAsk, + // not ask(), so the ask count alone cannot see a regression there. expect(mockCline.ask).toHaveBeenCalledTimes(1) + expect(mockCline.finalizePartialToolAsk).not.toHaveBeenCalled() // The teardown that already ran owns the rollback: no second one from the catch. expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 7f7546d790..6b462c313d 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -529,14 +529,25 @@ export class DiffViewProvider { } /** - * Release a diff view whose content was never approved (an abandoned partial - * stream). Deliberately NOT the same as revertChanges(): for a new-file edit that - * path saves a dirty buffer as-is before deleting the file, which would write - * partial model output the task never approved - and leave it on disk if the - * delete then fails. Here the buffer is emptied FIRST, so the only bytes that can - * ever reach the placeholder file are none; the tab is then clean enough to close - * without a prompt, and the placeholder plus the directories this edit created are - * removed. + * Release a diff view whose content was never approved: an abandoned partial stream, + * a failed stream, or a write the user was never asked to approve (rooignore denial, + * validation failure). Deliberately NOT the same as revertChanges(), because + * revertChanges() SAVES and neither abandoned case may write to disk: + * + * - create: its new-file branch saves a dirty buffer as-is before deleting the file, + * which writes partial model output the task never approved - and leaves it on disk + * if the delete then fails. + * - modify: its branch restores the original content and saves it. That is a write to + * a file the user never approved, and for a rooignore-denial path a write the + * policy forbids outright. + * + * Here the target file is never written. A create buffer is emptied FIRST, so the + * only bytes that can ever reach the placeholder are none and the tab is clean enough + * to close without a prompt; a modify buffer is restored to the content already on + * disk in memory only, so the file itself is untouched and the tab (now showing the + * original content, still marked dirty) is left for the user rather than force-closed + * over any edits they may have typed into the preview. The placeholder plus the + * directories this edit created are removed either way. */ async discardUnapprovedStream(): Promise { if (!this.relPath) { @@ -569,14 +580,30 @@ export class DiffViewProvider { await this.closeAllDiffViews() if (document.isDirty) { + // Restore the buffer to what this edit started from - the empty placeholder for a + // create, the content already on disk for a modify. A failed applyEdit leaves the + // unapproved streamed content in the buffer, and saving then would persist exactly + // what this method exists to discard, so the save is conditional and the failure is + // surfaced to the caller as a rollback hazard. const edit = new vscode.WorkspaceEdit() const fullRange = new vscode.Range( document.positionAt(0), document.positionAt(document.getText().length), ) - edit.replace(document.uri, fullRange, "") - await vscode.workspace.applyEdit(edit) - await document.save() + const restoredContent = this.editType === "modify" ? this.stripAllBOMs(this.originalContent ?? "") : "" + edit.replace(document.uri, fullRange, restoredContent) + const applied = await vscode.workspace.applyEdit(edit) + if (!applied) { + editorFailure = new Error( + `Could not restore the diff editor buffer for ${this.relPath}; it may still hold unapproved content.`, + ) + } else if (this.editType !== "modify") { + // Only the emptied placeholder is ever saved: this edit created that file, and + // closing a dirty tab would prompt. A modify is never saved here - a modify's + // restore is an in-memory revert, and saving it would write to a file the user + // never approved (see the method comment). + await document.save() + } } await this.closeFileTab(absolutePath) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d781fa1848..0c73f5af98 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1917,6 +1917,60 @@ describe("DiffViewProvider", () => { // The placeholder for THIS relPath is what gets removed. expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) }) + + it("restores a modify buffer in memory and never saves the target file", async () => { + // revertChanges() restores a modify by applyEdit + document.save(). For a stream the + // user never approved that save is a write they never asked for, and for a + // .rooignore-denied path a write the policy forbids outright, so the discard restores + // the buffer and leaves the file on disk untouched. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + Object.assign(diffViewProvider, { + editType: "modify", + originalContent: "original content", + // A modify owns no placeholder: only open() of a create writes one. + placeholderPath: undefined, + }) + vi.mocked(vscode.workspace.applyEdit).mockImplementation(async () => { + callOrder.push("applyEdit") + return true + }) + + await diffViewProvider.discardUnapprovedStream() + + expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "closeFileTab"]) + const [replacedUri, , replacedText] = mockWorkspaceEdit.replace.mock.calls[0] + expect(replacedUri).toEqual(document.uri) + expect(replacedText).toBe("original content") + // The replacement covers the whole buffer, so no streamed content survives it. + expect(vi.mocked(vscode.Range)).toHaveBeenCalledWith( + { line: 0, character: 0 }, + { line: 0, character: "partial model output".length }, + ) + expect(document.save).not.toHaveBeenCalled() + // Nothing is unlinked for a modify: saveChanges() may have written this very file with + // the user's approval, and this edit created no directories. + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + }) + + it("does not save and reports the hazard when the buffer restore fails to apply", async () => { + // applyEdit resolving false leaves the unapproved partial content in the buffer. Saving + // then would persist exactly what this method exists to discard, so the save is skipped + // and the caller is told the rollback did not happen. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(false) + + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow(/could not restore the diff editor buffer/i) + + expect(document.save).not.toHaveBeenCalled() + // The artifacts still have to go: the caller runs reset() next, which drops + // placeholderPath/createdDirs and leaves nothing else able to remove them. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + }) it("removes the directories this edit created in reverse order, after the placeholder unlink", async () => { const callOrder: string[] = [] From 9500c072bffe8684fcd904054fde97cd97e71f5c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:25:02 +0800 Subject: [PATCH 27/29] fix(abort-r1-u7): end the stream session on every disposal path A cancelled stream could still do work after its owner was gone, in three places: - handlePartial() re-checked stream identity before awaiting diffViewProvider.update() but not after. When the abort landed inside that await, the continuation returned as if it still owned a live stream. It now re-checks and hands off: the view it streamed into already existed when the TaskAborted teardown ran, so that teardown owns it - the continuation waits for the disposal's own reversion (new Task.waitForDiffReversion) instead of starting a second discard over the same buffer, then drops the provider references the released stream points at. - discardUnapprovedStream() left isEditing set and the diff editor referenced. A task disposed while streaming runs that discard with no reset() after it, so the disposed task kept a live diff view and a late continuation still read a session as open. - The per-task stream state lives in a tool singleton and was released only by the TaskAborted listener registered with it, or by the one provider path that calls clearTaskState(). dispose() removes every listener, so a direct disposal kept the entry, the task, and its provider forever; the release now sits on the path every disposal takes, next to the listener teardown. Three regression tests, one per defect, each killing exactly that fix. The spec file also picks up the indentation prettier wants: the committed copy of the discardUnapprovedStream block is one level deep, and the repository lost its .prettierignore on main, so the whole-repo format check now reaches it. --- src/core/task/Task.ts | 22 +- src/core/task/__tests__/Task.dispose.test.ts | 29 + src/core/tools/WriteToFileTool.ts | 21 +- .../tools/__tests__/writeToFileTool.spec.ts | 37 +- src/integrations/editor/DiffViewProvider.ts | 17 +- .../editor/__tests__/DiffViewProvider.spec.ts | 704 +++++++++--------- 6 files changed, 481 insertions(+), 349 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d332a9b54f..f5ec8bf08b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -110,6 +110,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" +import { writeToFileTool } from "../tools/WriteToFileTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" @@ -3540,6 +3541,12 @@ export class Task extends EventEmitter implements TaskLike { // Remove all event listeners to prevent memory leaks. try { + // The per-task write_to_file stream state lives in a tool singleton and is + // normally released by the TaskAborted listener registered with it. removeAllListeners + // above drops that listener, so a task disposed directly - without an abort - would + // leave the singleton holding this task, its provider, and a stream that can never + // advance again. Release it here, where every disposal path passes. + writeToFileTool.clearTaskState(this) this.removeAllListeners() } catch (error) { console.error("Error removing event listeners:", error) @@ -3608,7 +3615,10 @@ export class Task extends EventEmitter implements TaskLike { // persistTaskMetadata() awaits that promise and teardown must not wait on it. Once the name // is known the await is skipped inside persistTaskMetadata(), so the repair is safe to run // and skipping it would leave the history entry stale for a task that could repair it. - if (this.pendingTaskMetadataRepair && (this.taskApiConfigReadySettled || this._taskApiConfigName !== undefined)) { + if ( + this.pendingTaskMetadataRepair && + (this.taskApiConfigReadySettled || this._taskApiConfigName !== undefined) + ) { await this.persistTaskMetadata() } @@ -3616,6 +3626,16 @@ export class Task extends EventEmitter implements TaskLike { await this.diffReversionPromise } + /** + * The diff teardown this disposal started, for a continuation that observes its own + * stream released mid-await. Single-owner by construction: the in-flight + * handlePartial() waits for the discard Task.dispose() already started instead of + * running an independent one over the same buffer. + */ + public async waitForDiffReversion(): Promise { + await this.diffReversionPromise + } + // Subtasks // Spawn / Wait / Complete diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 388f1faa77..98b5a2ea46 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -1,3 +1,4 @@ +import { writeToFileTool } from "../../tools/WriteToFileTool" import path from "node:path" import { type ProviderSettings, RooCodeEventName } from "@roo-code/types" @@ -472,4 +473,32 @@ describe("Task.run() idempotency", () => { await t.dispose() startTaskSpy.mockRestore() }) + + test("dispose() releases the write_to_file stream state this task registered", async () => { + // The per-task stream state lives in a tool singleton and is normally released by the + // TaskAborted listener registered with it. dispose() removes every listener, so a task + // disposed directly - the path ClineProvider.cleanupFailedHistoryTask() takes - would + // leave the singleton holding this task, its provider, and a stream that can never + // advance again. + // Spied before construction: a constructor that fails can start the disposal itself, + // and that path has to release the state too. The release is captured in a local + // rather than read back off the spy, because mockRestore() clears the spy's call + // history and the assertion below runs after it. + const released: unknown[] = [] + const clearTaskState = vi.spyOn(writeToFileTool, "clearTaskState").mockImplementation((disposed) => { + released.push(disposed) + }) + const t = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "hello", + startTask: false, + }) + try { + await t.dispose() + } finally { + clearTaskState.mockRestore() + } + expect(released).toEqual([t]) + }) }) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 2f5d55ac4d..37b60baa24 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -641,13 +641,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { isProtected: isWriteProtected, } - partialMessage = JSON.stringify(sharedMessageProps) + partialMessage = JSON.stringify(sharedMessageProps) await task.ask("tool", partialMessage, block.partial).catch(() => {}) if (!this.isPartialStreamStillLive(task, partialStreamState)) { - return - } - + return + } } catch (error) { // Unexpected failure in the pre-streaming setup (provider state, the filesystem probe, the // partial ask): this delta never reaches the diff view or execute(), so nothing else @@ -677,6 +676,18 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, false, ) + + // Cancellation can land while update() is in flight. The view it streamed into + // already existed when the TaskAborted teardown ran, so that teardown owns it: + // starting a second discard here would roll the same buffer back twice and report + // the hazard twice. Wait for the task's own reversion instead, then drop the + // provider references this released stream still points at. Without the re-check + // the continuation returns as if it still owned a live stream. + if (!this.isPartialStreamStillLive(task, partialStreamState)) { + await task.waitForDiffReversion() + await this.resetDiffViewAfterWrite(task) + return + } } catch (error) { // A cancellation that lands while open() or update() is in flight runs the // TaskAborted teardown - which releases this task's stream state, reverts or @@ -724,4 +735,4 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } } -export const writeToFileTool = new WriteToFileTool() \ No newline at end of file +export const writeToFileTool = new WriteToFileTool() diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 53f81d8a72..e7a88a9db5 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -514,10 +514,7 @@ describe("writeToFileTool", () => { expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() - expect(mockCline.say).toHaveBeenCalledWith( - "error", - expect.stringContaining("could not be restored"), - ) + expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("could not be restored")) }) it("discards a modify teardown too, because revertChanges() would save it", async () => { @@ -557,7 +554,6 @@ describe("writeToFileTool", () => { expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) - it("stops before updating the diff view when the task is cancelled while open() is in flight", async () => { // open() is the first provider await after the ask. If TaskAborted lands while // it is in flight, the teardown has already released this task's stream state @@ -579,6 +575,37 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) }) + + it("waits for the task's own discard instead of starting a second one when the abort lands during update()", async () => { + // The view update() streams into already existed when the TaskAborted teardown ran, so + // that teardown owns it. Without a re-check after the await the continuation returns as + // if it still owned a live stream; with an independent discard it would roll the same + // buffer back twice and report the hazard twice. + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + mockCline.diffViewProvider.isEditing = true + mockCline.diffViewProvider.update.mockClear() + mockCline.diffViewProvider.discardUnapprovedStream.mockClear() + mockCline.diffViewProvider.revertChanges.mockClear() + mockCline.diffViewProvider.reset.mockClear() + mockCline.waitForDiffReversion = vi.fn().mockResolvedValue(undefined) + // The cancellation lands inside update(), the last provider await of the delta. + mockCline.diffViewProvider.update.mockImplementationOnce(async () => { + writeToFileTool.clearTaskState(mockCline) + }) + + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockCline.diffViewProvider.update).toHaveBeenCalledTimes(1) + // Single owner: the continuation waits for the discard the disposal already started. + expect(mockCline.waitForDiffReversion).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.discardUnapprovedStream).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + // It still drops the provider references the released stream points at. + expect(mockCline.diffViewProvider.reset).toHaveBeenCalledTimes(1) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) it("discards the diff view that open() publishes after the stream state was released", async () => { // The TaskAborted teardown can only release the view that existed when it ran. When open() // completes AFTER the release it publishes a view nobody owns any more: for a create that diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 6b462c313d..06c94f47cd 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -590,7 +590,8 @@ export class DiffViewProvider { document.positionAt(0), document.positionAt(document.getText().length), ) - const restoredContent = this.editType === "modify" ? this.stripAllBOMs(this.originalContent ?? "") : "" + const restoredContent = + this.editType === "modify" ? this.stripAllBOMs(this.originalContent ?? "") : "" edit.replace(document.uri, fullRange, restoredContent) const applied = await vscode.workspace.applyEdit(edit) if (!applied) { @@ -643,6 +644,20 @@ export class DiffViewProvider { console.error("Error removing abandoned write_to_file artifacts:", error) } + // The stream session is over whichever way this returns. Task.dispose() runs no reset() + // after the discard, so leaving isEditing true with the editor still referenced would + // keep a disposed task pointing at a live diff view - and the idempotency guard in + // WriteToFileTool.discardDiffViewOpenedAfterRelease() reads isEditing to decide whether + // a late continuation still has work to do. reset() itself is not called here: it closes + // the diff views again, and the callers that do run it still do. + this.isEditing = false + this.editType = undefined + this.activeDiffEditor = undefined + this.originalContent = undefined + this.streamedLines = [] + this.userTouchedDocument = false + this.userTouchedDiffEditor = false + if (editorFailure) { throw editorFailure instanceof Error ? editorFailure : new Error(String(editorFailure)) } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 0c73f5af98..845c9aa846 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1857,380 +1857,410 @@ describe("DiffViewProvider", () => { expect(closeFileTab).not.toHaveBeenCalled() expect(vscode.window.showTextDocument).toHaveBeenCalled() }) - // An abandoned partial stream was never approved. revertChanges() saves a dirty - // new-file buffer before deleting the file, which would put partial model output on - // disk (and leave it there if the delete fails), so the discard path blanks the - // buffer first and only then saves the empty placeholder before removing it. - describe("discardUnapprovedStream method", () => { - const makeAbandonedDocument = (callOrder: string[], isDirty = true) => ({ - isDirty, - getText: () => "partial model output", - positionAt: (offset: number) => ({ line: 0, character: offset }), - uri: { fsPath: mockTargetPath, path: mockTargetPath }, - save: vi.fn(async () => { - callOrder.push("save") - }), - }) - - const openAbandonedView = (document: unknown, createdDirs: string[], callOrder: string[]) => - Object.assign(diffViewProvider, { - relPath: "mock-target-file.ts", - activeDiffEditor: { document }, - editType: "create", - createdDirs, - // open() records the placeholder it wrote; the discard may only delete what it owns. - placeholderPath: `${mockCwd}/mock-target-file.ts`, - closeAllDiffViews: vi.fn(async () => { - callOrder.push("closeDiffViews") + // An abandoned partial stream was never approved. revertChanges() saves a dirty + // new-file buffer before deleting the file, which would put partial model output on + // disk (and leave it there if the delete fails), so the discard path blanks the + // buffer first and only then saves the empty placeholder before removing it. + describe("discardUnapprovedStream method", () => { + const makeAbandonedDocument = (callOrder: string[], isDirty = true) => ({ + isDirty, + getText: () => "partial model output", + positionAt: (offset: number) => ({ line: 0, character: offset }), + uri: { fsPath: mockTargetPath, path: mockTargetPath }, + save: vi.fn(async () => { + callOrder.push("save") }), - closeFileTab: vi.fn(async () => { - callOrder.push("closeFileTab") - }), - }) - - it("never persists the unapproved buffer: blanks it with an empty replacement, then deletes the placeholder", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [], callOrder) - vi.mocked(vscode.workspace.applyEdit).mockImplementation(async () => { - callOrder.push("applyEdit") - return true }) - - await diffViewProvider.discardUnapprovedStream() - - expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "save", "closeFileTab"]) - // The only content the discard may write is an empty buffer, and it must be - // written to THIS document before the save that would otherwise persist the - // partial model output. - expect(mockWorkspaceEdit.replace).toHaveBeenCalledTimes(1) - const [replacedUri, , replacedText] = mockWorkspaceEdit.replace.mock.calls[0] - expect(replacedUri).toEqual(document.uri) - // The replacement covers the whole buffer: the range is built from - // positionAt(0) to positionAt(getText().length), so nothing can survive it. - expect(vi.mocked(vscode.Range)).toHaveBeenCalledWith( - { line: 0, character: 0 }, - { line: 0, character: "partial model output".length }, - ) - expect(replacedText).toBe("") - expect(mockWorkspaceEdit.delete).not.toHaveBeenCalled() - // The placeholder for THIS relPath is what gets removed. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) - }) - it("restores a modify buffer in memory and never saves the target file", async () => { - // revertChanges() restores a modify by applyEdit + document.save(). For a stream the - // user never approved that save is a write they never asked for, and for a - // .rooignore-denied path a write the policy forbids outright, so the discard restores - // the buffer and leaves the file on disk untouched. - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [], callOrder) - Object.assign(diffViewProvider, { - editType: "modify", - originalContent: "original content", - // A modify owns no placeholder: only open() of a create writes one. - placeholderPath: undefined, - }) - vi.mocked(vscode.workspace.applyEdit).mockImplementation(async () => { - callOrder.push("applyEdit") - return true + const openAbandonedView = (document: unknown, createdDirs: string[], callOrder: string[]) => + Object.assign(diffViewProvider, { + relPath: "mock-target-file.ts", + activeDiffEditor: { document }, + editType: "create", + createdDirs, + // open() records the placeholder it wrote; the discard may only delete what it owns. + placeholderPath: `${mockCwd}/mock-target-file.ts`, + closeAllDiffViews: vi.fn(async () => { + callOrder.push("closeDiffViews") + }), + closeFileTab: vi.fn(async () => { + callOrder.push("closeFileTab") + }), + }) + + it("leaves no session behind for a task that was disposed while the discard ran", async () => { + // Task.dispose() starts this discard and runs no reset() afterwards, so the discard + // itself has to end the session. isEditing and the editor reference are what a late + // handlePartial() continuation reads to decide whether it still owns a view, and a + // disposed task must not keep either - nor the streamed lines it just discarded. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + diffViewProvider.isEditing = true + diffViewProvider["streamedLines"] = ["partial model output"] + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await diffViewProvider.discardUnapprovedStream() + + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider.editType).toBeUndefined() + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + expect(diffViewProvider["streamedLines"]).toEqual([]) }) - await diffViewProvider.discardUnapprovedStream() - - expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "closeFileTab"]) - const [replacedUri, , replacedText] = mockWorkspaceEdit.replace.mock.calls[0] - expect(replacedUri).toEqual(document.uri) - expect(replacedText).toBe("original content") - // The replacement covers the whole buffer, so no streamed content survives it. - expect(vi.mocked(vscode.Range)).toHaveBeenCalledWith( - { line: 0, character: 0 }, - { line: 0, character: "partial model output".length }, - ) - expect(document.save).not.toHaveBeenCalled() - // Nothing is unlinked for a modify: saveChanges() may have written this very file with - // the user's approval, and this edit created no directories. - expect(fs.unlink).not.toHaveBeenCalled() - expect(fs.rmdir).not.toHaveBeenCalled() - }) - - it("does not save and reports the hazard when the buffer restore fails to apply", async () => { - // applyEdit resolving false leaves the unapproved partial content in the buffer. Saving - // then would persist exactly what this method exists to discard, so the save is skipped - // and the caller is told the rollback did not happen. - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [], callOrder) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(false) - - await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow(/could not restore the diff editor buffer/i) - - expect(document.save).not.toHaveBeenCalled() - // The artifacts still have to go: the caller runs reset() next, which drops - // placeholderPath/createdDirs and leaves nothing else able to remove them. - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) - }) - - it("removes the directories this edit created in reverse order, after the placeholder unlink", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - const createdDirs = [`${mockCwd}/mock-dir`, `${mockCwd}/mock-dir/nested`] - openAbandonedView(document, createdDirs, callOrder) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - - await diffViewProvider.discardUnapprovedStream() - - expect(fs.unlink).toHaveBeenCalledTimes(1) - expect(fs.rmdir).toHaveBeenCalledTimes(createdDirs.length) - // Reverse order, so a parent is never removed before the child inside it, and - // only the directories this edit created - never a pre-existing one. - expect(vi.mocked(fs.rmdir).mock.calls.map((c) => c[0])).toEqual([...createdDirs].reverse()) - const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] - for (const order of vi.mocked(fs.rmdir).mock.invocationCallOrder) { - expect(order).toBeGreaterThan(unlinkOrder) - } - }) - - it("does not save when the abandoned buffer is already clean", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder, false) - openAbandonedView(document, [], callOrder) - - await diffViewProvider.discardUnapprovedStream() - - expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() - expect(document.save).not.toHaveBeenCalled() - expect(mockWorkspaceEdit.replace).not.toHaveBeenCalled() - expect(fs.unlink).toHaveBeenCalledTimes(1) - }) - - it("still removes the placeholder and the created directories when the editor work rejects, and reports the failure", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - const createdDirs = [`${mockCwd}/mock-dir`] - openAbandonedView(document, createdDirs, callOrder) - vi.mocked(vscode.workspace.applyEdit).mockRejectedValue(new Error("applyEdit rejected")) - - // The caller (releaseAbandonedDiffView) treats a throw as "rollback hazard", so - // the failure must still surface - but only after the artifacts are gone, because - // the caller then runs reset(), which drops relPath/createdDirs and leaves - // nothing else able to remove them. - await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") - - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) - expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) - }) - - it("removes the placeholder and created directories even when open() failed before activeDiffEditor was assigned", async () => { - // open() creates the directories (line 130) and the empty placeholder (line 134) - // BEFORE openDiffEditor() assigns activeDiffEditor (line 173). If that call rejects - // - the 10s timeout or a failed vscode.diff - an abandoned create therefore has - // artifacts on disk and no editor, and the early return must not skip the cleanup. - Object.assign(diffViewProvider, { - relPath: "mock-target-file.ts", - activeDiffEditor: undefined, - editType: "create", - createdDirs: [`${mockCwd}/mock-dir`], - // open() had already written the placeholder before openDiffEditor() rejected, - // so this edit owns the path. - placeholderPath: `${mockCwd}/mock-target-file.ts`, + it("never persists the unapproved buffer: blanks it with an empty replacement, then deletes the placeholder", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockImplementation(async () => { + callOrder.push("applyEdit") + return true + }) + + await diffViewProvider.discardUnapprovedStream() + + expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "save", "closeFileTab"]) + // The only content the discard may write is an empty buffer, and it must be + // written to THIS document before the save that would otherwise persist the + // partial model output. + expect(mockWorkspaceEdit.replace).toHaveBeenCalledTimes(1) + const [replacedUri, , replacedText] = mockWorkspaceEdit.replace.mock.calls[0] + expect(replacedUri).toEqual(document.uri) + // The replacement covers the whole buffer: the range is built from + // positionAt(0) to positionAt(getText().length), so nothing can survive it. + expect(vi.mocked(vscode.Range)).toHaveBeenCalledWith( + { line: 0, character: 0 }, + { line: 0, character: "partial model output".length }, + ) + expect(replacedText).toBe("") + expect(mockWorkspaceEdit.delete).not.toHaveBeenCalled() + // The placeholder for THIS relPath is what gets removed. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) }) - await diffViewProvider.discardUnapprovedStream() - - expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) - expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) - // Nothing editor-side ran, and nothing threw: the artifacts are the whole job here. - expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() - // Bracket access for the private field: the snapshot must be consumed, so a second - // discard cannot try to remove the same directories again. - expect(diffViewProvider["createdDirs"]).toEqual([]) - }) - - it("logs and rejects when the placeholder unlink fails for a reason other than ENOENT", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [], callOrder) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - const permissionError = Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) - vi.mocked(fs.unlink).mockRejectedValueOnce(permissionError) - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + it("restores a modify buffer in memory and never saves the target file", async () => { + // revertChanges() restores a modify by applyEdit + document.save(). For a stream the + // user never approved that save is a write they never asked for, and for a + // .rooignore-denied path a write the policy forbids outright, so the discard restores + // the buffer and leaves the file on disk untouched. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + Object.assign(diffViewProvider, { + editType: "modify", + originalContent: "original content", + // A modify owns no placeholder: only open() of a create writes one. + placeholderPath: undefined, + }) + vi.mocked(vscode.workspace.applyEdit).mockImplementation(async () => { + callOrder.push("applyEdit") + return true + }) + + await diffViewProvider.discardUnapprovedStream() + + expect(callOrder).toEqual(["closeDiffViews", "applyEdit", "closeFileTab"]) + const [replacedUri, , replacedText] = mockWorkspaceEdit.replace.mock.calls[0] + expect(replacedUri).toEqual(document.uri) + expect(replacedText).toBe("original content") + // The replacement covers the whole buffer, so no streamed content survives it. + expect(vi.mocked(vscode.Range)).toHaveBeenCalledWith( + { line: 0, character: 0 }, + { line: 0, character: "partial model output".length }, + ) + expect(document.save).not.toHaveBeenCalled() + // Nothing is unlinked for a modify: saveChanges() may have written this very file with + // the user's approval, and this edit created no directories. + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + }) - await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("EPERM: operation not permitted") + it("does not save and reports the hazard when the buffer restore fails to apply", async () => { + // applyEdit resolving false leaves the unapproved partial content in the buffer. Saving + // then would persist exactly what this method exists to discard, so the save is skipped + // and the caller is told the rollback did not happen. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(false) + + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow( + /could not restore the diff editor buffer/i, + ) - expect(errorSpy).toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", permissionError) - }) + expect(document.save).not.toHaveBeenCalled() + // The artifacts still have to go: the caller runs reset() next, which drops + // placeholderPath/createdDirs and leaves nothing else able to remove them. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + }) - it("logs and rejects when removing a created directory fails for a reason other than ENOENT", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - vi.mocked(fs.unlink).mockResolvedValue(undefined) - const notEmpty = Object.assign(new Error("ENOTEMPTY: directory not empty"), { code: "ENOTEMPTY" }) - vi.mocked(fs.rmdir).mockRejectedValueOnce(notEmpty) - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + it("removes the directories this edit created in reverse order, after the placeholder unlink", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + const createdDirs = [`${mockCwd}/mock-dir`, `${mockCwd}/mock-dir/nested`] + openAbandonedView(document, createdDirs, callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledTimes(createdDirs.length) + // Reverse order, so a parent is never removed before the child inside it, and + // only the directories this edit created - never a pre-existing one. + expect(vi.mocked(fs.rmdir).mock.calls.map((c) => c[0])).toEqual([...createdDirs].reverse()) + const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] + for (const order of vi.mocked(fs.rmdir).mock.invocationCallOrder) { + expect(order).toBeGreaterThan(unlinkOrder) + } + }) - await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("ENOTEMPTY: directory not empty") + it("does not save when the abandoned buffer is already clean", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder, false) + openAbandonedView(document, [], callOrder) - expect(errorSpy).toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", notEmpty) - }) + await diffViewProvider.discardUnapprovedStream() - it("tolerates an already-deleted placeholder and directories (ENOENT) without throwing", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - // Two created directories: the tolerance has to hold for every one of them, so the - // rejection is keyed on the path rather than on which call comes first. - const dirA = `${mockCwd}/mock-dir-a` - const dirB = `${mockCwd}/nested/mock-dir-b` - openAbandonedView(document, [dirA, dirB], callOrder) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) - // A single unlink call in this flow, so the placeholder stays Once-based. - vi.mocked(fs.unlink).mockRejectedValueOnce(enoent) - vi.mocked(fs.rmdir).mockImplementation(async (dir) => { - if (dir === dirA || dir === dirB) { - throw enoent - } + expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() + expect(document.save).not.toHaveBeenCalled() + expect(mockWorkspaceEdit.replace).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledTimes(1) }) - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() + it("still removes the placeholder and the created directories when the editor work rejects, and reports the failure", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + const createdDirs = [`${mockCwd}/mock-dir`] + openAbandonedView(document, createdDirs, callOrder) + vi.mocked(vscode.workspace.applyEdit).mockRejectedValue(new Error("applyEdit rejected")) + + // The caller (releaseAbandonedDiffView) treats a throw as "rollback hazard", so + // the failure must still surface - but only after the artifacts are gone, because + // the caller then runs reset(), which drops relPath/createdDirs and leaves + // nothing else able to remove them. + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) + }) - // Both directories were attempted; the assertion is on the paths, not the order. - expect(fs.rmdir).toHaveBeenCalledWith(dirA) - expect(fs.rmdir).toHaveBeenCalledWith(dirB) - expect(errorSpy).not.toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", expect.anything()) + it("removes the placeholder and created directories even when open() failed before activeDiffEditor was assigned", async () => { + // open() creates the directories (line 130) and the empty placeholder (line 134) + // BEFORE openDiffEditor() assigns activeDiffEditor (line 173). If that call rejects + // - the 10s timeout or a failed vscode.diff - an abandoned create therefore has + // artifacts on disk and no editor, and the early return must not skip the cleanup. + Object.assign(diffViewProvider, { + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs: [`${mockCwd}/mock-dir`], + // open() had already written the placeholder before openDiffEditor() rejected, + // so this edit owns the path. + placeholderPath: `${mockCwd}/mock-target-file.ts`, + }) + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("mock-target-file.ts")) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/mock-dir`) + // Nothing editor-side ran, and nothing threw: the artifacts are the whole job here. + expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() + // Bracket access for the private field: the snapshot must be consumed, so a second + // discard cannot try to remove the same directories again. + expect(diffViewProvider["createdDirs"]).toEqual([]) + }) - // beforeEach only clears call data, not implementations, so put the default back. - vi.mocked(fs.rmdir).mockResolvedValue(undefined) - }) + it("logs and rejects when the placeholder unlink fails for a reason other than ENOENT", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + const permissionError = Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }) + vi.mocked(fs.unlink).mockRejectedValueOnce(permissionError) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow( + "EPERM: operation not permitted", + ) - it("reports the editor failure rather than the cleanup failure when both happen", async () => { - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) - vi.mocked(vscode.workspace.applyEdit).mockRejectedValue(new Error("applyEdit rejected")) - vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("EPERM"), { code: "EPERM" })) - vi.spyOn(console, "error").mockImplementation(() => {}) - - // The caller reports a rollback hazard from the thrown error, so the ORIGINAL failure - // is what must surface; the cleanup failure is still logged for the operator. - await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") - }) - - it("leaves a file this edit never created a placeholder for on disk", async () => { - // reset() does not clear relPath, and saveDirectly() sets it for an approved write. - // Without ownership tracking the discard would unlink that approved file: an approved - // write_to_file to A, then a new write_to_file whose block exits through a rollback - // path before open() ever ran, still sees relPath === A. - const callOrder: string[] = [] - const document = makeAbandonedDocument(callOrder) - Object.assign(diffViewProvider, { - relPath: "previously-approved-file.ts", - activeDiffEditor: { document }, - editType: undefined, - createdDirs: [], - placeholderPath: undefined, + expect(errorSpy).toHaveBeenCalledWith( + "Error removing abandoned write_to_file artifacts:", + permissionError, + ) }) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() - - expect(fs.unlink).not.toHaveBeenCalled() - expect(fs.rmdir).not.toHaveBeenCalled() - // The buffer is still blanked and the tab closed: the unapproved content is gone - // without touching a file the user approved. - expect(mockWorkspaceEdit.replace).toHaveBeenCalledTimes(1) - expect(mockWorkspaceEdit.replace.mock.calls[0][2]).toBe("") - }) + it("logs and rejects when removing a created directory fails for a reason other than ENOENT", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + const notEmpty = Object.assign(new Error("ENOTEMPTY: directory not empty"), { code: "ENOTEMPTY" }) + vi.mocked(fs.rmdir).mockRejectedValueOnce(notEmpty) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow( + "ENOTEMPTY: directory not empty", + ) - it("removes the placeholder that a new-file open() wrote, driven through the public methods", async () => { - // The ownership lifecycle lives at the public-method layer, so exercise it there: - // open() writes the empty placeholder and records it, and the discard of an abandoned - // create removes exactly that path. - const relPath = "owned-placeholder.ts" - const fsPath = `${mockCwd}/${relPath}` - const mockEditor = { - document: { - uri: { fsPath, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), - lineCount: 0, - }, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - edit: vi.fn().mockResolvedValue(true), - revealRange: vi.fn(), - } - // Structural double for the mocked editor: the mock only implements the members - // open() touches, so it is routed through unknown rather than any. - const editor = mockEditor as unknown as vscode.TextEditor - vi.mocked(vscode.window).visibleTextEditors = [editor] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(editor) - vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback({ uri: { fsPath, scheme: "file" } } as vscode.TextDocument), 0) - return { dispose: vi.fn() } + expect(errorSpy).toHaveBeenCalledWith("Error removing abandoned write_to_file artifacts:", notEmpty) }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - vi.mocked(vscode.window.onDidChangeTextEditorVisibleRanges).mockReturnValue({ dispose: vi.fn() }) - vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) - diffViewProvider.editType = "create" - await diffViewProvider.open(relPath) + it("tolerates an already-deleted placeholder and directories (ENOENT) without throwing", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + // Two created directories: the tolerance has to hold for every one of them, so the + // rejection is keyed on the path rather than on which call comes first. + const dirA = `${mockCwd}/mock-dir-a` + const dirB = `${mockCwd}/nested/mock-dir-b` + openAbandonedView(document, [dirA, dirB], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + const enoent = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + // A single unlink call in this flow, so the placeholder stays Once-based. + vi.mocked(fs.unlink).mockRejectedValueOnce(enoent) + vi.mocked(fs.rmdir).mockImplementation(async (dir) => { + if (dir === dirA || dir === dirB) { + throw enoent + } + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() + + // Both directories were attempted; the assertion is on the paths, not the order. + expect(fs.rmdir).toHaveBeenCalledWith(dirA) + expect(fs.rmdir).toHaveBeenCalledWith(dirB) + expect(errorSpy).not.toHaveBeenCalledWith( + "Error removing abandoned write_to_file artifacts:", + expect.anything(), + ) - // open() created the placeholder, and this edit now owns it. - expect(fs.writeFile).toHaveBeenCalledWith(fsPath, "") + // beforeEach only clears call data, not implementations, so put the default back. + vi.mocked(fs.rmdir).mockResolvedValue(undefined) + }) - await diffViewProvider.discardUnapprovedStream() + it("reports the editor failure rather than the cleanup failure when both happen", async () => { + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + openAbandonedView(document, [`${mockCwd}/mock-dir`], callOrder) + vi.mocked(vscode.workspace.applyEdit).mockRejectedValue(new Error("applyEdit rejected")) + vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("EPERM"), { code: "EPERM" })) + vi.spyOn(console, "error").mockImplementation(() => {}) + + // The caller reports a rollback hazard from the thrown error, so the ORIGINAL failure + // is what must surface; the cleanup failure is still logged for the operator. + await expect(diffViewProvider.discardUnapprovedStream()).rejects.toThrow("applyEdit rejected") + }) - expect(fs.unlink).toHaveBeenCalledWith(fsPath) - }) + it("leaves a file this edit never created a placeholder for on disk", async () => { + // reset() does not clear relPath, and saveDirectly() sets it for an approved write. + // Without ownership tracking the discard would unlink that approved file: an approved + // write_to_file to A, then a new write_to_file whose block exits through a rollback + // path before open() ever ran, still sees relPath === A. + const callOrder: string[] = [] + const document = makeAbandonedDocument(callOrder) + Object.assign(diffViewProvider, { + relPath: "previously-approved-file.ts", + activeDiffEditor: { document }, + editType: undefined, + createdDirs: [], + placeholderPath: undefined, + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await expect(diffViewProvider.discardUnapprovedStream()).resolves.toBeUndefined() + + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + // The buffer is still blanked and the tab closed: the unapproved content is gone + // without touching a file the user approved. + expect(mockWorkspaceEdit.replace).toHaveBeenCalledTimes(1) + expect(mockWorkspaceEdit.replace.mock.calls[0][2]).toBe("") + }) - it("does not remove the file once saveChanges() has approved the content", async () => { - // saveChanges() turns the placeholder into approved content and drops this edit's - // claim on it, so a later abandoned cleanup must leave the approved file on disk. - const relPath = "approved-by-save.ts" - const fsPath = `${mockCwd}/${relPath}` - Object.assign(diffViewProvider, { - relPath, - newContent: "approved content", - editType: "create", - createdDirs: [], - placeholderPath: fsPath, - preDiagnostics: [], - activeDiffEditor: { + it("removes the placeholder that a new-file open() wrote, driven through the public methods", async () => { + // The ownership lifecycle lives at the public-method layer, so exercise it there: + // open() writes the empty placeholder and records it, and the discard of an abandoned + // create removes exactly that path. + const relPath = "owned-placeholder.ts" + const fsPath = `${mockCwd}/${relPath}` + const mockEditor = { document: { uri: { fsPath, scheme: "file" }, - getText: vi.fn().mockReturnValue("approved content"), + getText: vi.fn().mockReturnValue(""), isDirty: false, save: vi.fn().mockResolvedValue(undefined), + lineCount: 0, }, - }, - closeAllDiffViews: vi.fn().mockResolvedValue(undefined), - closeFileTab: vi.fn().mockResolvedValue(undefined), + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + // Structural double for the mocked editor: the mock only implements the members + // open() touches, so it is routed through unknown rather than any. + const editor = mockEditor as unknown as vscode.TextEditor + vi.mocked(vscode.window).visibleTextEditors = [editor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(editor) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath, scheme: "file" } } as vscode.TextDocument), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.window.onDidChangeTextEditorVisibleRanges).mockReturnValue({ dispose: vi.fn() }) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + diffViewProvider.editType = "create" + + await diffViewProvider.open(relPath) + + // open() created the placeholder, and this edit now owns it. + expect(fs.writeFile).toHaveBeenCalledWith(fsPath, "") + + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).toHaveBeenCalledWith(fsPath) }) - vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(undefined as never) - vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - await diffViewProvider.saveChanges(false, 0) + it("does not remove the file once saveChanges() has approved the content", async () => { + // saveChanges() turns the placeholder into approved content and drops this edit's + // claim on it, so a later abandoned cleanup must leave the approved file on disk. + const relPath = "approved-by-save.ts" + const fsPath = `${mockCwd}/${relPath}` + Object.assign(diffViewProvider, { + relPath, + newContent: "approved content", + editType: "create", + createdDirs: [], + placeholderPath: fsPath, + preDiagnostics: [], + activeDiffEditor: { + document: { + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue("approved content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + }, + closeAllDiffViews: vi.fn().mockResolvedValue(undefined), + closeFileTab: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(undefined as never) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) - await diffViewProvider.discardUnapprovedStream() + await diffViewProvider.saveChanges(false, 0) - expect(fs.unlink).not.toHaveBeenCalled() - }) + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).not.toHaveBeenCalled() + }) + + it("does nothing when no abandoned view is open", async () => { + Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) - it("does nothing when no abandoned view is open", async () => { - Object.assign(diffViewProvider, { relPath: undefined, activeDiffEditor: undefined }) - - await diffViewProvider.discardUnapprovedStream() - - expect(fs.unlink).not.toHaveBeenCalled() + await diffViewProvider.discardUnapprovedStream() + + expect(fs.unlink).not.toHaveBeenCalled() + }) }) }) - }) }) - - From b2b3b0d5c259e0e0f7ab5a7f3f63583266462cb6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:45:21 +0800 Subject: [PATCH 28/29] style: format the three spec files the format check reaches The repository's ignore list no longer covers them, so the format job reads them as committed: two carry CRLF line endings and one has wrapped arguments prettier wants unfolded. Content is untouched - the same 195 tests pass before and after. --- ...ssistantMessage-missing-native-args.spec.ts | 10 ++++------ src/core/task/__tests__/Task.spec.ts | 4 +++- ...iteToFileTool-partial-state-cleanup.spec.ts | 18 +++++++++++++----- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts index 688807b22e..9efd86bfac 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-missing-native-args.spec.ts @@ -148,12 +148,10 @@ describe("presentAssistantMessage - finalized block without nativeArgs", () => { nativeArgs: { path: "same.ts", content: "same content" }, }, ] - vi.mocked(mockTask.toolRepetitionDetector as unknown as { check: unknown }).check = vi - .fn() - .mockReturnValue({ - allowExecution: false, - askUser: { messageKey: "tool_repetition", messageDetail: "write_to_file" }, - }) + vi.mocked(mockTask.toolRepetitionDetector as unknown as { check: unknown }).check = vi.fn().mockReturnValue({ + allowExecution: false, + askUser: { messageKey: "tool_repetition", messageDetail: "write_to_file" }, + }) await presentAssistantMessage(mockTask as unknown as Parameters[0]) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index e7942cf95d..aac20f825e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6785,7 +6785,9 @@ describe("Cline", () => { const historyGate = new Promise((resolve) => { releaseHistory = resolve }) - const historySpy = vi.spyOn(mockProvider, "updateTaskHistory").mockImplementation(() => historyGate.then(() => [])) + const historySpy = vi + .spyOn(mockProvider, "updateTaskHistory") + .mockImplementation(() => historyGate.then(() => [])) const task = new Task({ provider: mockProvider, diff --git a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts index d52ac3104e..14edf46c6a 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -58,7 +58,10 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { writeToFileTool.clearTaskState(task) expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) - expect((task as unknown as CleanupTask).off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, state.abortCleanup) + expect((task as unknown as CleanupTask).off).toHaveBeenCalledWith( + RooCodeEventName.TaskAborted, + state.abortCleanup, + ) }) it("is a no-op for a task that never streamed", async () => { @@ -89,7 +92,10 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { await writeToFileTool["revertDiffChangesBeforeReset"](task) - expect(errorSpy).toHaveBeenCalledWith("Error releasing the abandoned write_to_file diff view:", expect.any(Error)) + expect(errorSpy).toHaveBeenCalledWith( + "Error releasing the abandoned write_to_file diff view:", + expect.any(Error), + ) }) it("reverts while the recovery state exists and reports the hazard when the revert fails", async () => { @@ -110,7 +116,9 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { task, // Only handleError is reached when no streaming error was recorded; the // structural double is the existing pattern in this file. - { handleError: vi.fn().mockResolvedValue(undefined) } as unknown as Parameters[1], + { handleError: vi.fn().mockResolvedValue(undefined) } as unknown as Parameters< + (typeof writeToFileTool)["onParameterParseFailure"] + >[1], new Error("parameter parse failed"), ) @@ -138,7 +146,7 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { const handled = await writeToFileTool["onParameterParseFailure"]( task, - { handleError } as unknown as Parameters[1], + { handleError } as unknown as Parameters<(typeof writeToFileTool)["onParameterParseFailure"]>[1], new Error("parameter parse failed"), ) @@ -163,7 +171,7 @@ describe("WriteToFileTool per-task partial-state cleanup", () => { const handled = await writeToFileTool["onParameterParseFailure"]( task, - { handleError } as unknown as Parameters[1], + { handleError } as unknown as Parameters<(typeof writeToFileTool)["onParameterParseFailure"]>[1], new Error("parameter parse failed"), ) From f873a3d9f512d6a0acd4501d0db3fa98cf34b942 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:07:26 +0800 Subject: [PATCH 29/29] refactor(task): drop the unreachable return in persistTaskMetadata Every path through the try/catch already reports this stage's result: false when the provider is gone, false from the catch, true after the history entry is written. The trailing return could never run, and it contradicted the contract the method now documents. No behaviour changes - which is also why no test moves when it comes back. --- src/core/task/Task.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f5ec8bf08b..fc317b3d26 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1842,8 +1842,6 @@ export class Task extends EventEmitter implements TaskLike { console.error("Failed to save task metadata:", error) return false } - - return true } private findMessageByTimestamp(ts: number): ClineMessage | undefined {