From 5693b89feaf778778a7ccdaa1d55038f6e84b22b Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:54:50 +0800 Subject: [PATCH 01/19] fix(write-to-file): capture a streaming failure once and roll it back honestly This is the U5 unit content re-based onto main's tip 73931fe8be (sha read from ls-remote of upstream refs/heads/main at the moment of re-basing, and re-read at commit time: unchanged), not merged into it. The parent is main, so main is an ancestor of this head and the PR diff is the unit's own 14 paths - one commit, no merge commit, no 105-file refresh riding along. Why re-base rather than merge. The unit's spec work lands in src/integrations/editor/__tests__/ DiffViewProvider.spec.ts, which main rewrote onto its typed test harness (main's copy: 1780 lines, 70 titles, 0 'as any', 55 makeTextEditor calls; the branch's copy: 2400 lines, 90 titles, 292 'as any'). Merging forced the two harnesses to coexist; re-basing keeps main's helper form and ports the unit's assertions onto it. Which revertChanges() behaviour this carries, and the evidence for it. The unit's rollback rewrite is: - the early-return guard moves from !activeDiffEditor to !isEditing, with the stated reason: relPath survives a completed edit, so a later teardown would otherwise take the new-file branch for the previous edit's target and unlink an existing user file; - a dirty buffer holding unapproved streamed content is restored to its pre-stream content and saved clean before the tab is closed (restorePreStreamBuffer -> saveBufferClean -> close) instead of being saved as-is, because saving it persisted exactly what the rollback is undoing - local history and file watchers, and the content itself if the delete then fails; - refused applyEdit/save/close now fail closed and surface, and revertChanges reports whether it performed a rollback. main asserts none of that: its spec has 0 revertChanges tests mentioning isDirty, the merge base has 1 (a revealRange test with isDirty: false), and the branch's copy has 11, ten of them on titles present on neither main nor the merge base. No title present on main and the merge base is absent from the branch. So the branch is additive on this seam and main's semantics are not displaced. What I changed in a main-side assertion, and why. No assertion moved: the three close/keep tests ('keeps the file open when the user touched it', 'closes the file tab when the file was not open and untouched', 'closes the file tab even when userTouchedDiffEditor is true (deny ignores diff-touch)') have identical assertion lists on main and on the branch - closeFileTab called/not called plus showTextDocument called/not called. The guard move did mean four main tests needed a two-line fixture addition (relPath plus isEditing on their own target), because their fixtures staged only activeDiffEditor and would now exit at the guard. That is a fixture, not an assertion, and the control below shows it is load-bearing rather than cosmetic. Controls on the ported spec (vitest, same machine, same order, one file each): - replication: ported spec against this head's implementation - 92 passed (92), exit 0. - control A: ported spec against MAIN's DiffViewProvider.ts - 18 failed | 74 passed (92), and every failure is one of the unit's own new cases (restore-before-close, fail-closed on a refused applyEdit/save/close, rollback-reported boolean, ENOENT-tolerant rmdir, stale-target teardown). The ported assertions therefore test the unit's behaviour, not main's. - control B: MAIN's own unmodified spec against this head's implementation - 4 failed | 68 passed (72), and the four are exactly the four tests the fixture lines were added to, including the deny-ignores-diff-touch one. So main's assertions are genuinely exercised by the new implementation and they hold once the fixture declares the edit the guard requires. After restoring both files the replication re-ran to 92 passed (92) again, so the controls did not leave the tree altered. Ports needed to put the unit's assertions on main's harness: - buildActiveDiffEditor takes a construction-time dirty flag (docOverrides.isDirty into makeTextDocument), main's own idiom; the 10 post-hoc editor.document.isDirty = true writes became construction overrides, which is what the readonly type requires. - 292 'as any' casts in the branch's copy became bracket writes or typed doubles; the file now has 0. - the fs/promises mock gained unlink and rmdir, the two members the merged rollback code calls; both were missing from main's factory and the branch had added them for the same reason. Verification on this head, pinned pnpm 10.8.1, hooks running: - eslint --max-warnings=0 --no-warn-ignored over all 13 touched .ts paths: exit 0 each, run against src/eslint-suppressions.json restored to main's blob and without --prune-suppressions, and that file is byte-identical to main's blob before and after, so no suppression count moved. - pnpm --dir src exec tsc --noEmit: exit 0, 0 errors. - DiffViewProvider.spec 92 passed (92); writeToFileTool.spec 59 passed | 5 skipped (64); writeToFileTool-partial-state-cleanup 10 passed (10); presentAssistantMessage-write-to-file-stream- cleanup 6 passed (6); removeClineFromStack-delegation 24 passed (24); Task.dispose.test 14 passed (14); Task.spec 211 passed (211); ClineProvider.spec 339 passed (339). --- .../removeClineFromStack-delegation.spec.ts | 42 + ...ssage-write-to-file-stream-cleanup.spec.ts | 357 ++++++ .../presentAssistantMessage.ts | 48 +- src/core/task/Task.ts | 27 +- src/core/task/__tests__/Task.dispose.test.ts | 13 +- src/core/task/__tests__/Task.spec.ts | 25 + src/core/tools/BaseTool.ts | 34 +- src/core/tools/WriteToFileTool.ts | 602 +++++++-- ...teToFileTool-partial-state-cleanup.spec.ts | 275 +++++ .../tools/__tests__/writeToFileTool.spec.ts | 1091 ++++++++++++++++- src/core/webview/ClineProvider.ts | 6 + src/integrations/editor/DiffViewProvider.ts | 231 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 683 ++++++++++- 13 files changed, 3315 insertions(+), 119 deletions(-) create mode 100644 src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts create mode 100644 src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts diff --git a/src/__tests__/removeClineFromStack-delegation.spec.ts b/src/__tests__/removeClineFromStack-delegation.spec.ts index 0db228662e..1cf842d96b 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 PrivateClineProviderMethods = { removeClineFromStack: (this: ClineProvider) => ReturnType @@ -202,6 +203,47 @@ 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, + rollbackFailure: 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/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts new file mode 100644 index 0000000000..50125a9055 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts @@ -0,0 +1,357 @@ +// Regression coverage for the malformed-completion path. +// +// presentAssistantMessage emits its own tool_result and returns for a completed known-tool block +// that has no nativeArgs, so tool.handle() is never reached and BaseTool's parse-failure teardown +// - the one this chain added - never runs. These tests drive the presenter itself, which is how a +// malformed streamed write_to_file actually reaches the teardown. + +import { RooCodeEventName } from "@roo-code/types" +import { afterEach, beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest" + +import { type Task } from "../../task/Task" +import { DispatchState } from "../../task/dispatchState" +import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" +import { isValidToolName } from "../../tools/validateToolUse" +import { writeToFileTool } from "../../tools/WriteToFileTool" +import { fileExistsAtPath } from "../../../utils/fs" + +import { presentAssistantMessage } from "../presentAssistantMessage" + +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn().mockReturnValue(true), +})) +// Only the filesystem probe and the readable-path helper have to be observable; every other +// export of those modules stays real, so the streaming path runs against the real code. +vi.mock("../../../utils/fs", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fileExistsAtPath: vi.fn().mockResolvedValue(false), + createDirectoriesForFile: vi.fn().mockResolvedValue([]), + } +}) +vi.mock("../../../utils/path", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getReadablePath: (_cwd: string, relPath: string) => relPath } +}) +vi.mock("../../../utils/pathUtils", () => ({ + isPathOutsideWorkspace: vi.fn().mockReturnValue(false), +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureException: vi.fn(), + }, + }, +})) + +interface PushedToolResult { + type: string + tool_use_id: string + content: string + is_error?: boolean +} + +// Structural double: the presenter and the streaming path only read these members. The double +// assertion in asTask() is the repo's existing pattern for presenter-level tests (see +// writeToFileTool-partial-state-cleanup.spec.ts); Task itself needs a live extension host. +interface PresenterTask { + taskId: string + instanceId: string + cwd: string + abort: boolean + abandoned: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + currentStreamingDidCheckpoint: boolean + assistantMessageContent: Array> + userMessageContent: PushedToolResult[] + didCompleteReadingStream: boolean + userMessageContentReady: boolean + activeToolContexts: Map + dispatchState: DispatchState + pendingWork: null + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + api: { getModel: () => { id: string; info: Record } } + getTaskMode: MockedFunction<() => Promise> + recordToolUsage: MockedFunction<(name: string) => void> + recordToolError: MockedFunction<(name: string, text?: string) => void> + toolRepetitionDetector: { check: MockedFunction<() => { allowExecution: boolean }> } + providerRef: { deref: () => { getState: () => Promise> } } + say: MockedFunction<(type: string, text?: string, images?: unknown) => Promise> + ask: MockedFunction<(type: string, text?: string, partial?: boolean) => Promise<{ response: string }>> + once: MockedFunction<(event: string, listener: () => void) => unknown> + off: MockedFunction<(event: string, listener: () => void) => unknown> + finalizePartialToolAsk: MockedFunction<(text?: string) => Promise> + pushToolResultToUserContent: MockedFunction<(result: PushedToolResult) => boolean> + diffViewProvider: { + editType: "modify" | "create" | undefined + isEditing: boolean + open: MockedFunction<(relPath: string) => Promise> + update: MockedFunction<(content: string, single: boolean) => Promise> + reset: MockedFunction<() => Promise> + revertChanges: MockedFunction<() => Promise> + } +} + +const CALL_ID = "toolu_write_to_file_malformed" +const STREAM_FAILURE = "EACCES: permission denied, open 'src/demo.ts'" + +function buildTask(): PresenterTask { + const diffViewProvider: PresenterTask["diffViewProvider"] = { + editType: undefined, + isEditing: false, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + } + // Mirror the real provider: an open edit is what the rollback below is allowed to revert, + // and both revert and reset end the edit. + diffViewProvider.open.mockImplementation(async () => { + diffViewProvider.isEditing = true + }) + diffViewProvider.revertChanges.mockImplementation(async () => { + diffViewProvider.isEditing = false + }) + diffViewProvider.reset.mockImplementation(async () => { + diffViewProvider.isEditing = false + }) + + const task: PresenterTask = { + taskId: "task-1", + instanceId: "instance-1", + cwd: "/mock/cwd", + abort: false, + abandoned: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + currentStreamingDidCheckpoint: true, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + userMessageContentReady: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + api: { getModel: () => ({ id: "test-model", info: {} }) }, + getTaskMode: vi.fn().mockResolvedValue("code"), + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { check: vi.fn().mockReturnValue({ allowExecution: true }) }, + providerRef: { deref: () => ({ getState: async () => ({ experiments: {}, customModes: [] }) }) }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + once: vi.fn(), + off: vi.fn(), + finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), + pushToolResultToUserContent: vi.fn((result: PushedToolResult) => { + task.userMessageContent.push(result) + return true + }), + diffViewProvider, + activeToolContexts: new Map(), + dispatchState: DispatchState.IDLE, + pendingWork: null, + } + return task +} + +// The double above is the documented shape of every presenter test in this directory. +const asTask = (task: PresenterTask): Task => task as unknown as Task + +// The presenter validates against the request's frozen policy snapshot; the double has no policy. +const baseSnapshot: RequestPolicySnapshot = { disabledTools: [], customModes: [], experiments: {} } + +async function present(task: PresenterTask, block: Record): Promise { + task.assistantMessageContent = [block] + task.currentStreamingContentIndex = 0 + await presentAssistantMessage(asTask(task), baseSnapshot) +} + +const partialDelta = (content: string): Record => ({ + type: "tool_use", + id: CALL_ID, + name: "write_to_file", + params: { path: "src/demo.ts", content }, + partial: true, +}) + +// The completed block the parser could not finalize: no nativeArgs. +const malformedCompletion = (): Record => ({ + type: "tool_use", + id: CALL_ID, + name: "write_to_file", + params: {}, + partial: false, +}) + +const toolResultsFor = (task: PresenterTask): PushedToolResult[] => + task.userMessageContent.filter((block) => block.type === "tool_result" && block.tool_use_id === CALL_ID) + +const errorSays = (task: PresenterTask): unknown[][] => task.say.mock.calls.filter(([type]) => type === "error") + +// The listener this task registered, read from the registration itself rather than any function. +const registeredAbortListener = (task: PresenterTask): unknown => + task.once.mock.calls.find(([event]) => event === RooCodeEventName.TaskAborted)?.[1] + +// Streams a delta whose diff-view update fails: handlePartial marks the stream failed and keeps +// the error for the authoritative report. The path only counts as stable once it has been seen +// twice, so the first delta only registers the entry and the second reaches the diff view. +async function streamFailedDelta(task: PresenterTask): Promise { + task.diffViewProvider.update.mockRejectedValueOnce(new Error(STREAM_FAILURE)) + await present(task, partialDelta("partial content")) + await present(task, partialDelta("partial content")) +} + +describe("presentAssistantMessage - abandoned write_to_file stream", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isValidToolName).mockReturnValue(true) + vi.mocked(fileExistsAtPath).mockReset().mockResolvedValue(false) + vi.spyOn(console, "error").mockImplementation(() => {}) + writeToFileTool.resetPartialState() + }) + + afterEach(() => { + writeToFileTool["taskPartialStreamState"].clear() + vi.restoreAllMocks() + }) + + it("releases the abandoned stream state and reports the captured failure once", async () => { + const task = buildTask() + await streamFailedDelta(task) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + const abortListener = registeredAbortListener(task) + expect(abortListener).toBeInstanceOf(Function) + // The stream's own rollback already ran; clear it so the counts below only describe the + // completion path. + task.diffViewProvider.revertChanges.mockClear() + task.diffViewProvider.reset.mockClear() + + await present(task, malformedCompletion()) + + // The leak: the entry and its TaskAborted listener outlived the call. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(task.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + // Exactly one tool_result for this tool_use_id, carrying the failure the user can act on + // rather than the incidental "missing nativeArgs" text. + const results = toolResultsFor(task) + expect(results).toHaveLength(1) + expect(results[0].is_error).toBe(true) + expect(results[0].content).toContain(STREAM_FAILURE) + expect(results[0].content).not.toContain("missing nativeArgs") + // Counted by channel rather than toHaveBeenCalledWith: a count of matching says cannot + // auto-pass when the error row is emitted more than once. + const says = errorSays(task) + expect(says).toHaveLength(1) + expect(says[0][1]).toContain("Error writing file:") + // The stream had already reverted, so only the reset is left to do. + expect(task.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(task.diffViewProvider.reset).toHaveBeenCalledTimes(1) + }) + + it("restores the diff document an abandoned stream left open", async () => { + const task = buildTask() + writeToFileTool["getTaskPartialStreamState"](asTask(task)) + task.diffViewProvider.isEditing = true + + await present(task, malformedCompletion()) + + // revertChanges() must run before reset(): reset clears the state the rollback reads, and + // a dirty streamed buffer that survives it is saved by the next open() before approval. + expect(task.diffViewProvider.revertChanges).toHaveBeenCalledTimes(1) + expect(task.diffViewProvider.reset).toHaveBeenCalledTimes(1) + expect(task.diffViewProvider.revertChanges.mock.invocationCallOrder[0]).toBeLessThan( + task.diffViewProvider.reset.mock.invocationCallOrder[0], + ) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + // No streaming failure was captured, so the malformed-call report stays the actionable one. + const results = toolResultsFor(task) + expect(results).toHaveLength(1) + expect(results[0].content).toContain("missing nativeArgs") + expect(errorSays(task)).toHaveLength(0) + }) + + it("keeps the missing-nativeArgs result for a task that never streamed", async () => { + const task = buildTask() + + await present(task, malformedCompletion()) + + const results = toolResultsFor(task) + expect(results).toHaveLength(1) + expect(results[0].is_error).toBe(true) + expect(results[0].content).toContain("missing nativeArgs") + expect(errorSays(task)).toHaveLength(0) + // The cleanup must not create state, touch the diff view, or register a listener for a + // task that never streamed a write. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(task.once).not.toHaveBeenCalled() + expect(task.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(task.diffViewProvider.reset).not.toHaveBeenCalled() + }) + + it("lets the next write in the same task stream its diff preview again", async () => { + const task = buildTask() + await streamFailedDelta(task) + const opensAfterFailure = task.diffViewProvider.open.mock.calls.length + + await present(task, malformedCompletion()) + + // A retained streamFailed mark makes handlePartial() skip its work for every later delta, + // so this task would never show a diff preview again. + await present(task, partialDelta("next write")) + await present(task, partialDelta("next write")) + + expect(task.diffViewProvider.open.mock.calls.length).toBe(opensAfterFailure + 1) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + }) + + it("still emits the required tool_result when the error row cannot be saved", async () => { + const task = buildTask() + await streamFailedDelta(task) + task.say.mockRejectedValue(new Error("chat row unavailable")) + + await expect(present(task, malformedCompletion())).resolves.toBeUndefined() + + // A rejected chat row must not strand the turn: the provider still needs exactly one + // tool_result for this tool_use_id, and the block still has to be marked complete. + const results = toolResultsFor(task) + expect(results).toHaveLength(1) + expect(results[0].content).toContain(STREAM_FAILURE) + // The lost chat row must not change which failure is reported: the hook still answered + // that its failure replaces the parse error. + expect(results[0].content).not.toContain("missing nativeArgs") + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(task.userMessageContentReady).toBe(true) + }) + + it("keeps the malformed-call error when the rollback itself failed", async () => { + const task = buildTask() + writeToFileTool["getTaskPartialStreamState"](asTask(task)) + task.diffViewProvider.isEditing = true + task.diffViewProvider.revertChanges.mockRejectedValue(new Error("restore refused")) + + await present(task, malformedCompletion()) + + // A refused rollback is a second, distinct failure: the hook reports it and still returns + // false, so the one tool_result has to carry both it and the malformed-call error - the + // model otherwise never learns the call could not be finalized. + const results = toolResultsFor(task) + expect(results).toHaveLength(1) + expect(results[0].content).toContain("write_to_file rollback failed: restore refused") + expect(results[0].content).toContain("missing nativeArgs") + const says = errorSays(task) + expect(says).toHaveLength(1) + expect(says[0][1]).toContain("Error writing file:") + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 73d48b2e7c..948d8e4a92 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -577,12 +577,58 @@ async function presentAssistantMessageBlock(cline: Task, policySnapshot: Request // Best-effort only } + // This guard returns before tool.handle(), so handle()'s parse-failure teardown + // never runs on the malformed-completion path: the per-task stream entry and its + // TaskAborted listener would outlive the call, a retained streamFailed mark would + // suppress this task's later diff previews, and a diff document the stream opened + // would keep content the user never approved. Release this task's state before + // emitting the result. The guard owns the single tool_result a native tool call + // must produce, so the tool's handleError folds the failure it reports into that + // one result instead of pushing a second one. + const abandonedStreamFailure: { report?: string } = {} + let reportedStreamFailure = false + if (block.name === "write_to_file") { + try { + reportedStreamFailure = await writeToFileTool.releaseStreamStateOnParseFailure(cline, { + handleError: async (action, error) => { + // Store the report before touching the UI: the failure text is the only + // copy of it, and a chat row that cannot be saved must not cost the model + // the tool_result this guard owes the provider. + abandonedStreamFailure.report = `Error ${action}: ${JSON.stringify(serializeError(error))}` + try { + await cline.say("error", `Error ${action}:\n${error.message}`) + } catch (reportError) { + // A chat row that cannot be saved must not cost the model the tool_result + // this guard owes the provider, and it must not change which failure that + // result reports: letting it escape here would also lose the hook's answer + // on whether the parse error is still worth mentioning. + console.error("Error saving abandoned write_to_file error row:", reportError) + } + }, + }) + } catch (teardownError) { + // The teardown protects this call from a leak; it must never be the reason the + // mandatory tool_result and the block-completion bookkeeping below are skipped. + console.error("Error releasing abandoned write_to_file stream state:", teardownError) + } + } + // The hook returns true when the failure it reported replaces the parse error. When it + // reports anyway and still returns false - a refused rollback with no captured streaming + // error is a second, distinct failure - the model must also learn the call could not be + // finalized, so both facts go into the one tool_result. + let resultContent = errorMessage + if (abandonedStreamFailure.report) { + resultContent = reportedStreamFailure + ? abandonedStreamFailure.report + : `${abandonedStreamFailure.report}\n${errorMessage}` + } + // Push tool_result directly without setting didAlreadyUseTool so streaming can // continue gracefully. cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: sanitizeToolUseId(toolCallId), - content: formatResponse.toolError(errorMessage), + content: formatResponse.toolError(resultContent), is_error: true, }) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b911479bc6..ec4bda6177 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -105,6 +105,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" @@ -1464,6 +1465,11 @@ export class Task extends EventEmitter implements TaskLike { /** Cancels the current persistence generation before creating the next assistant-turn boundary. */ private resetAssistantMessagePersistence(): void { this.cancelAssistantMessagePersistence() + // The refused-rollback marker is deliberately NOT released here. This runs at the start of + // every API request (and once in the constructor), and a marker means the dirty streamed + // buffer is still on disk: releasing it here lets the retried stream call open() on that + // still-dirty document, which saves unapproved content before approval. disposeOnce() is the + // only path that may drop it, because only there is the task unable to restore anything. this.assistantMessagePersistencePromise = new Promise((resolve) => { this.resolveAssistantMessagePersistence = resolve }) @@ -3696,6 +3702,11 @@ export class Task extends EventEmitter implements TaskLike { this.cancelAssistantMessagePersistence() this.cancelPendingPartialMessageUpdate() + // Disposal is the one place a refused-rollback marker may be dropped: this task will never + // serve another request, so it can no longer re-open the dirty buffer it refused to restore, + // and a marker left behind would outlive the task in the tool's per-task map. + writeToFileTool.releaseTaskRollbackFailure(this) + // Release handler-owned host resources (e.g. VS Code configuration listeners). // This is the fallback for leases that never settle; later lease releases // find nothing retired and so cannot dispose twice. @@ -3788,7 +3799,12 @@ 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) + // The field records that the reversion settled. Whether a rollback actually ran is + // revertChanges()'s own report, and a disposing task has nothing left to act on either way. + this.diffReversionPromise = this.diffViewProvider + .revertChanges() + .then(() => undefined) + .catch(console.error) } } catch (error) { console.error("Error reverting diff changes:", error) @@ -4208,6 +4224,15 @@ export class Task extends EventEmitter implements TaskLike { await this.diffViewProvider.reset() + // A new API request starts a new stream. Clear any per-task partial-stream failure + // state left by a previous stream that failed without aborting (streaming_failed): + // otherwise the retried write would keep skipping previews and fail on the stale + // rollback error before it ever writes. A REFUSED rollback is the exception: + // clearTaskState() keeps that marker until the dirty buffer is restored (or the + // failure is reported), so the retry — this request — still fails closed instead + // of letting open() save unapproved streamed content. + writeToFileTool.clearTaskState(this) + await this.safeEnsureModelFetched() // Cache model info once per API request to avoid repeated calls during streaming diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 606568b923..6658863581 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -190,7 +190,7 @@ describe("Task dispose method", () => { test("should await diff reversion during abort without waiting for output cleanup", async () => { let resolveCleanup!: () => void - let resolveReversion!: () => void + let resolveReversion!: (restored: boolean) => void vi.mocked(OutputInterceptor.cleanup).mockReturnValueOnce( new Promise((resolve) => { resolveCleanup = resolve @@ -199,7 +199,7 @@ describe("Task dispose method", () => { task.isStreaming = true task.diffViewProvider.isEditing = true const revertChangesSpy = vi.spyOn(task.diffViewProvider, "revertChanges").mockReturnValue( - new Promise((resolve) => { + new Promise((resolve) => { resolveReversion = resolve }), ) @@ -210,7 +210,7 @@ describe("Task dispose method", () => { await vi.waitFor(() => expect(revertChangesSpy).toHaveBeenCalledOnce()) expect(saveMessages).not.toHaveBeenCalled() - resolveReversion() + resolveReversion(true) await abort expect(saveMessages).toHaveBeenCalledOnce() @@ -227,8 +227,8 @@ describe("Task dispose method", () => { }) test("should expose completion of deferred diff reversion", async () => { - let resolveReversion: () => void - const reversion = new Promise((resolve) => { + let resolveReversion: (restored: boolean) => void + const reversion = new Promise((resolve) => { resolveReversion = resolve }) task.isStreaming = true @@ -245,7 +245,8 @@ describe("Task dispose method", () => { expect(disposalComplete).toBe(false) expect(revertChangesSpy).toHaveBeenCalledOnce() - resolveReversion!() + // A completed rollback: this is the reversion the deferred disposal is waiting for. + resolveReversion!(true) await disposal expect(disposalComplete).toBe(true) }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c1ee5a0b13..3cea259129 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6670,6 +6670,31 @@ describe("Cline", () => { expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) }) + it("keeps the refused-rollback marker across a new assistant turn and releases it on disposal", async () => { + // The marker means a dirty streamed buffer survived a refused restore. The per-request + // persistence reset (recursivelyMakeClineRequests -> resetAssistantMessagePersistence) must + // not drop it: releasing it there lets the retried stream call open() on the still-dirty + // document, which saves unapproved streamed content before approval. Disposal is the one + // path allowed to drop it, because a disposed task can no longer re-open anything. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const markerKey = `${task.taskId}.${task.instanceId}` + writeToFileTool["taskRollbackFailures"].set(markerKey, new Error("write_to_file rollback failed")) + + getTaskTestAccess(task).resetAssistantMessagePersistence() + expect(writeToFileTool["taskRollbackFailures"].has(markerKey)).toBe(true) + // The per-request tool-state clear keeps it as well. + writeToFileTool.clearTaskState(task) + expect(writeToFileTool["taskRollbackFailures"].has(markerKey)).toBe(true) + + await task.dispose() + expect(writeToFileTool["taskRollbackFailures"].has(markerKey)).toBe(false) + }) + it("stays silent when the api handler lacks ensureModelFetched", async () => { const task = new Task({ provider: mockProvider, diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 1e73a8285a..c9d7e53fa3 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -98,6 +98,31 @@ export abstract class BaseTool { this.lastSeenPartialPath = undefined } + /** + * Teardown boundary for a completed tool call that never reaches execute(): the + * parse-failure catch in handle(), and presentAssistantMessage's missing-nativeArgs + * guard, which emits its own tool_result and returns before handle() is reached. + * No-op for tools without per-task state; the scope is a single task because tool + * instances are singletons shared by concurrent tasks. + * + * Default: there is no per-task streaming state to release, so the generic parse + * error is what the user sees. A tool that keeps per-task stream state may release + * it, restore any diff document a stream opened, and report a more specific failure + * through callbacks.handleError - returning true suppresses the incidental parse + * error so the failure is reported exactly once. Per-task only: a global teardown + * would clobber another task that is still streaming through this singleton. + * + * Public because that caller sits outside handle(). Its guard owns the single + * tool_result a native tool call must produce, so it passes a handleError that folds + * the reported failure into that result instead of pushing a second one. + */ + async releaseStreamStateOnParseFailure( + _task: Task, + _callbacks: Pick, + ): Promise { + return false + } + /** * Main entry point for tool execution. * @@ -157,7 +182,14 @@ export abstract class BaseTool { } 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)) + // execute() never runs on this path, so a tool that keeps per-task streaming + // state must still release THIS task's state and restore any diff document the + // stream opened. If a streaming delta already hit a fatal error, the tool reports + // that (the actionable failure) and the incidental parse error is suppressed. + const reportedStreamFailure = await this.releaseStreamStateOnParseFailure(task, callbacks) + if (!reportedStreamFailure) { + 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 diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 0c5c80abb9..500bfb42af 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -1,7 +1,7 @@ import path from "path" 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" @@ -22,17 +22,317 @@ 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 + /** Set when the rollback after a streaming failure was itself refused: the dirty + * streamed buffer survives reset(), so the next execute() must fail closed - open() + * would save that unapproved buffer before approval. Reported once by execute() (or by + * onParameterParseFailure() when the final block never parses). */ + rollbackFailure: 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() + + /** + * Refused-rollback markers per task. These outlive resetTaskPartialState()/clearTaskState() + * because Task clears the per-stream state at the start of EVERY API request: if the dirty + * streamed buffer survived a refused rollback, the retry request must still fail closed + * instead of letting open() save that buffer before approval. Cleared once execute() reports + * the failure, or when the task is torn down. + */ + private taskRollbackFailures = new Map() + private reportedRollbackFailures = new WeakSet() + + 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, + rollbackFailure: 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. + */ + /** Release the refused-rollback marker for a task that is being torn down. */ + public releaseTaskRollbackFailure(task: Task): void { + this.taskRollbackFailures.delete(this.getPartialStreamFailureKey(task)) + } + + public clearTaskState(task: Task): void { + // The per-stream state is per request, but a refused rollback left the dirty buffer in + // place: taskRollbackFailures is intentionally NOT cleared here so the retry request + // still fails closed. It is released by execute() once reported, or by resetPartialState(). + 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. A revert failure is RETURNED rather than dropped: the caller records it as the + * failure this stream produced, so debris left on disk is reported instead of being + * silently continued past. + */ + private async revertDiffChangesBeforeReset(task: Task): Promise { + try { + await task.diffViewProvider.revertChanges() + } catch (revertError) { + console.error("Error reverting write_to_file diff view changes:", revertError) + return revertError instanceof Error ? revertError : new Error(String(revertError)) + } + return undefined + } + + /** + * Whether this task can still act on a partial delta. Mirrors the guard Task uses to bail + * out of its own loops, so a delta never does work for a task that has already stopped. + */ + private isStreamCancelled(task: Task): boolean { + return task.abort === true || task.abandoned === true + } + + 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 for the handle() parse-failure path, where execute() never runs and its + * cleanup never runs either. + * + * Releases the per-task stream state: otherwise the abort listener leaks for the task's + * lifetime, and a failed streaming delta leaves the streamFailed guard suppressing 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 here, so a user save could persist it. When a + * streaming delta already hit a fatal filesystem error, THAT is the failure the user can + * act on, so it is reported with the same "writing file" context execute()'s catch uses, + * and true is returned to suppress the incidental parse error - the failure surfaces + * exactly once. + * + * Also reached from presentAssistantMessage's missing-nativeArgs guard: that guard + * emits its own tool_result and returns before handle() runs, so this teardown is + * the only thing that can release the entry a stream left behind. Skip it and the + * per-task entry plus its TaskAborted listener outlive the call, the retained + * streamFailed mark suppresses this task's later diff previews, and a diff document + * the stream opened keeps content the user never approved. That guard passes a + * handleError that feeds the failure into the single tool_result it owns. + */ + override async releaseStreamStateOnParseFailure( + task: Task, + callbacks: Pick, + ): Promise { + const state = this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) + if (!state) { + return false + } + + const failureKey = this.getPartialStreamFailureKey(task) + this.resetTaskPartialState(task) + // Only an edit in progress may be reverted. DiffViewProvider keeps relPath after a + // completed write, so reverting against that stale target would roll back - and for a + // new file permanently delete - a file this stream never opened. + let rollbackError: Error | undefined + if (task.diffViewProvider.isEditing) { + rollbackError = await this.revertDiffChangesBeforeReset(task) + } + if (rollbackError) { + // The dirty buffer is still on disk after the refused rollback: keep the fail-closed + // marker on the TASK, not the per-stream state this teardown is about to delete. + this.taskRollbackFailures.set(failureKey, rollbackError) + } + await this.resetDiffViewAfterWrite(task) + + // A failed rollback is the more actionable failure (debris is still on disk), so when a + // streaming error was also captured it takes the report slot and the streaming error is + // kept behind it as the cause. Without a captured streaming error there was no streaming + // failure to describe: the rollback gets its own message and the parse error stays the + // actionable report. These are two distinct failures, so reporting both does not break the + // single-report rule. + if (rollbackError) { + this.reportedRollbackFailures.add(rollbackError) + if (state.streamError) { + await callbacks.handleError( + "writing file", + new Error(`write_to_file rollback failed after a streaming error: ${rollbackError.message}`, { + cause: state.streamError, + }), + ) + return true + } + await callbacks.handleError( + "writing file", + new Error(`write_to_file rollback failed: ${rollbackError.message}`, { cause: rollbackError }), + ) + return false + } + + if (state.streamError) { + await callbacks.handleError("writing file", state.streamError) + return true + } + + return false + } + + override resetPartialState(): void { + super.resetPartialState() + this.taskRollbackFailures.clear() + 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 let newContent = params.content + // Set when this execute() opens its own partial tool ask (diff-view branch), so + // the catch below can finalize it. Undefined on the saveDirectly branch. + let pendingPartialAsk: string | undefined + + // Fail closed on a refused streaming rollback: the dirty streamed buffer survived + // reset(), and open() saves a dirty existing document BEFORE approval - retrying the + // write here would persist content the user never approved. Report the recorded failure + // once and touch no write path (no open/update/save, no directory creation). The state + // is released here, so the parse-failure path - which only runs when execute() never + // did - can never report the same failure a second time. + const rollbackFailure = this.taskRollbackFailures.get(this.getPartialStreamFailureKey(task)) + const streamState = this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) + if (rollbackFailure || streamState?.rollbackFailure) { + const failureKey = this.getPartialStreamFailureKey(task) + const reported = rollbackFailure ?? streamState?.rollbackFailure + this.resetTaskPartialState(task) + // Keep the marker until the dirty buffer is actually restored: a later write to the + // same path would otherwise call open() and save the unapproved content. revertChanges() + // reports whether it performed a rollback - it resolves without touching anything when + // this provider no longer has an edit in progress, which is the state reset() leaves + // behind after a refused restore. Only a confirmed rollback clears the marker; a + // rejection and a no-op both keep the fail-closed refusal. + let restored = false + try { + restored = await task.diffViewProvider.revertChanges() + } catch (rollbackError) { + console.error("Error restoring the refused write_to_file rollback:", rollbackError) + } + if (restored) { + this.taskRollbackFailures.delete(failureKey) + } + // The parse-failure teardown may already have reported this exact failure: keep the + // fail-closed refusal but do not report the same Error twice. + if (!(reported && this.reportedRollbackFailures.has(reported))) { + await handleError("writing file", reported!) + } + return + } if (!relPath) { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") + // No execute() cleanup on this early return: release THIS task's stream state + // (and only this task's) so the abort listener and the streamFailed guard do not + // outlive the call. + this.resetTaskPartialState(task) pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "path")) await task.diffViewProvider.reset() return @@ -41,6 +341,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (newContent === undefined) { task.consecutiveMistakeCount++ task.recordToolError("write_to_file") + // No execute() cleanup on this early return: release THIS task's stream state + // (and only this task's) so the abort listener and the streamFailed guard do not + // outlive the call. + this.resetTaskPartialState(task) pushToolResult(await task.sayAndCreateMissingParamError("write_to_file", "content")) await task.diffViewProvider.reset() return @@ -49,53 +353,65 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) if (!accessAllowed) { - await task.say("rooignore_error", relPath) + try { + await task.say("rooignore_error", relPath) + } finally { + // The release belongs in a finally: task.say() can reject when the task is cancelled + // or disposed mid-ask, and a rejection that skips it leaks this task's stream state and + // its TaskAborted listener. finally keeps this branch the same shape as the sibling + // units, which run their own cleanup before releasing. + this.resetTaskPartialState(task) + } pushToolResult(formatResponse.rooIgnoreError(relPath)) return } const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false - let fileExists: boolean - const absolutePath = path.resolve(task.cwd, relPath) + try { + // The preflight filesystem work sits inside the guarded scope on purpose: a throw from + // fileExistsAtPath / createDirectoriesForFile must be reported like any other write + // failure, and the per-task stream state must still be released. - if (task.diffViewProvider.editType !== undefined) { - fileExists = task.diffViewProvider.editType === "modify" - } else { - fileExists = await fileExistsAtPath(absolutePath) - task.diffViewProvider.editType = fileExists ? "modify" : "create" - } + let fileExists: boolean + const absolutePath = path.resolve(task.cwd, relPath) - // 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 (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + fileExists = await fileExistsAtPath(absolutePath) + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } - if (newContent.startsWith("```")) { - newContent = newContent.split("\n").slice(1).join("\n") - } + // 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.endsWith("```")) { - newContent = newContent.split("\n").slice(0, -1).join("\n") - } + if (newContent.startsWith("```")) { + newContent = newContent.split("\n").slice(1).join("\n") + } - if (!task.api.getModel().id.includes("claude")) { - newContent = unescapeHtmlEntities(newContent) - } + if (newContent.endsWith("```")) { + newContent = newContent.split("\n").slice(0, -1).join("\n") + } - const fullPath = relPath ? path.resolve(task.cwd, relPath) : "" - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + if (!task.api.getModel().id.includes("claude")) { + newContent = unescapeHtmlEntities(newContent) + } - const sharedMessageProps: ClineSayTool = { - tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(task.cwd, relPath), - content: newContent, - isOutsideWorkspace, - isProtected: isWriteProtected, - } + const fullPath = relPath ? path.resolve(task.cwd, relPath) : "" + const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) + + const sharedMessageProps: ClineSayTool = { + tool: fileExists ? "editedExistingFile" : "newFileCreated", + path: getReadablePath(task.cwd, relPath), + content: newContent, + isOutsideWorkspace, + isProtected: isWriteProtected, + } - try { task.consecutiveMistakeCount = 0 const provider = task.providerRef.deref() @@ -136,6 +452,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } else { if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) + pendingPartialAsk = partialMessage await task.ask("tool", partialMessage, true).catch(() => {}) await task.diffViewProvider.open(relPath) } @@ -178,16 +495,32 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) await task.diffViewProvider.reset() - this.resetPartialState() + // BaseTool's reset only clears this instance's lastSeenPartialPath; the + // stream state added here is keyed per task. Clearing the whole map from + // BaseTool's reset only clears this instance's lastSeenPartialState; the per-task + // entry is released by the finally below (clearing the whole map from one task's + // execute() would drop another task's streamFailed/streamError while it is still + // streaming). task.processQueuedMessages() return } catch (error) { + // The diff-view branch above may have opened a fresh partial ask for this + // (retried) write. Finalize it before tearing down, or the spinner and + // Save/Reject buttons stay live for a tool call that has already failed. + if (pendingPartialAsk !== undefined) { + await this.finalizePartialToolAskAfterFailure(task, pendingPartialAsk) + } await handleError("writing file", error as Error) await task.diffViewProvider.reset() - this.resetPartialState() + super.resetPartialState() return + } finally { + // One teardown covers every exit of the guarded scope: success, both approval denials, + // the catch, and any throw from the preflight filesystem work. Idempotent, so the + // explicit releases on the early returns above stay correct. + this.resetTaskPartialState(task) } } @@ -195,62 +528,179 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const relPath: string | undefined = block.params.path const newContent: string | undefined = block.params.content - // Wait for path to stabilize before showing UI (prevents truncated paths) - if (!this.hasPathStabilized(relPath) || newContent === undefined) { + 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 } - const provider = task.providerRef.deref() - const state = await provider?.getState() - const isPreventFocusDisruptionEnabled = experiments.isEnabled( - state?.experiments ?? {}, - EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, - ) - - if (isPreventFocusDisruptionEnabled) { + // A task that was aborted or abandoned can no longer reach execute()'s teardown, so this + // delta must not register per-task state at all: the abort listener would outlive a stream + // that never produces another delta, and the entry's failure mark would suppress the diff + // preview of a later write in a task that already moved on. Same two flags Task itself + // bails on, and the same cancellation-aware shape #1929 established for the streamFailed + // guard: check before acquiring state, then re-check after every await before the next + // observable effect. + if (this.isStreamCancelled(task)) { return } - // relPath is guaranteed non-null after hasPathStabilized - let fileExists: boolean - const absolutePath = path.resolve(task.cwd, relPath!) + // 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) - if (task.diffViewProvider.editType !== undefined) { - fileExists = task.diffViewProvider.editType === "modify" - } else { - fileExists = await fileExistsAtPath(absolutePath) - task.diffViewProvider.editType = fileExists ? "modify" : "create" + // Wait for path to stabilize before showing UI (prevents truncated paths) + if (!this.hasPathStabilizedForTask(partialStreamState, relPath) || newContent === undefined) { + return } - // Create parent directories early for new files to prevent ENOENT errors - // in subsequent operations (e.g., diffViewProvider.open) - if (!fileExists) { - await createDirectoriesForFile(absolutePath) - } + // Hoisted above the guarded setup: the diff-view catch below finalizes the same partial + // ask, so the message has to stay in scope once the try block ends. + let partialMessage: string | undefined - const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false - const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + try { + // Everything from here up 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() + // First await since the state was registered: a cancellation during getState() would + // otherwise continue into the partial ask below. + if (this.isStreamCancelled(task)) { + this.resetTaskPartialState(task) + return + } + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) - const sharedMessageProps: ClineSayTool = { - tool: fileExists ? "editedExistingFile" : "newFileCreated", - path: getReadablePath(task.cwd, relPath!), - content: newContent || "", - isOutsideWorkspace, - isProtected: isWriteProtected, + 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!) + + if (task.diffViewProvider.editType !== undefined) { + fileExists = task.diffViewProvider.editType === "modify" + } else { + fileExists = await fileExistsAtPath(absolutePath) + task.diffViewProvider.editType = fileExists ? "modify" : "create" + } + // The filesystem probe is another await boundary, and the ask below is the first thing + // the user can see: a task that stopped must not produce it. + if (this.isStreamCancelled(task)) { + this.resetTaskPartialState(task) + return + } + + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + 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(() => {}) + } catch (error) { + // Unexpected failure in the pre-streaming setup (provider state, the filesystem probe, + // policy checks, message construction): this delta never reaches the diff view or + // execute(), so nothing else releases what the registration acquired. Drop this task's + // entry and its TaskAborted listener, then rethrow - BaseTool.handle() still reports the + // error once, and the diff-view failure path above keeps its own single-report handling. + this.resetTaskPartialState(task) + throw error } - const partialMessage = JSON.stringify(sharedMessageProps) - await task.ask("tool", partialMessage, block.partial).catch(() => {}) + // Last await before the diff view: the ask above can be answered (or the task aborted) + // while it is in flight, and opening a preview for a task that already stopped would + // leave a diff view nobody owns. + if (this.isStreamCancelled(task)) { + this.resetTaskPartialState(task) + return + } + + // A refused rollback left the streamed document dirty: opening a preview here would save + // that unapproved buffer before approval, so this delta stops and execute() owns the refusal + // report. The check sits outside the content guard on purpose: a delta that streamed an + // empty document still has its ask on screen, and that one has to be finalized too. + if (this.taskRollbackFailures.has(this.getPartialStreamFailureKey(task))) { + // The ask above is already on screen and this return is the last thing the delta does: + // finalize it, or the spinner and Save/Reject buttons stay live for a preview that will + // never be opened. execute() owns the refusal report. + await this.finalizePartialToolAskAfterFailure(task, partialMessage) + this.resetTaskPartialState(task) + return + } 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)) + // 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). + const rollbackError = await this.revertDiffChangesBeforeReset(task) + if (rollbackError) { + // The rollback did not finish: the placeholder and any created directories are still + // on disk, and the next execute() would treat that debris as an existing file. A + // logged-only revert failure hides exactly that, so the rollback failure becomes the + // failure this stream reports - the original streaming error stays reachable as the + // cause, and the report still happens exactly once (on the parse-failure path). + const rollbackFailure = new Error( + `write_to_file rollback failed after a streaming error: ${rollbackError.message}`, + { cause: partialStreamState.streamError }, + ) + partialStreamState.streamError = rollbackFailure + // Fail closed for the next execute(): reset() cannot close the dirty diff tab the + // refused restore left behind, so a retry would re-open the view and save the + // unapproved streamed content before approval. Record the failure on this task's + // state; execute() reports it once and touches no write path. + partialStreamState.rollbackFailure = rollbackFailure + this.taskRollbackFailures.set(this.getPartialStreamFailureKey(task), rollbackFailure) + } + await this.resetDiffViewAfterWrite(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 new file mode 100644 index 0000000000..c108050f2c --- /dev/null +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -0,0 +1,275 @@ +// 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" +import { fileExistsAtPath } from "../../../utils/fs" + +// Only the filesystem probe has to be observable; every other export of utils/fs stays real. +vi.mock("../../../utils/fs", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, fileExistsAtPath: vi.fn().mockResolvedValue(false) } +}) + +// 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 +} + +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) + // The registration side of the pairing, asserted BEFORE the cleanup runs: the off() + // assertion below only proves the right listener was deregistered if this one pins which + // listener was registered in the first place. + expect((task as unknown as CleanupTask).once).toHaveBeenCalledWith( + RooCodeEventName.TaskAborted, + state.abortCleanup, + ) + + 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("returns the revert failure so the caller can report the rollback failure", async () => { + const task = buildTask("revert-fails", "inst-4") + const t = task as unknown as CleanupTask + const revertError = new Error("revert failed") + t.diffViewProvider.revertChanges = vi.fn().mockRejectedValue(revertError) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(writeToFileTool["revertDiffChangesBeforeReset"](task)).resolves.toBe(revertError) + + 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)) + }) +}) +// handlePartial() acquires per-task state and then crosses several awaits before it touches +// anything the user can see. A task that was aborted or abandoned mid-flight must not leave the +// registration behind and must not produce the partial ask or a diff preview. +describe("WriteToFileTool partial-delta cancellation", () => { + // Structural double: handlePartial only reads these members before the diff view. The double + // assertion is the repo's existing pattern for private-method tests. + beforeEach(() => { + // The probe mock comes from a module-level vi.mock factory, so its call history outlives a + // single test unless it is reset here; the boundary assertions count on it. + vi.mocked(fileExistsAtPath).mockReset().mockResolvedValue(false) + }) + + interface StreamTask { + taskId: string + instanceId: string + abort: boolean + abandoned: boolean + cwd: string + once: MockedFunction<(...args: unknown[]) => unknown> + off: MockedFunction<(...args: unknown[]) => unknown> + ask: MockedFunction<(type: string, text: string, partial?: boolean) => Promise> + finalizePartialToolAsk: MockedFunction<(text?: string) => Promise> + providerRef: { deref: () => { getState: () => Promise> } } + diffViewProvider: { + editType: "modify" | "create" | undefined + isEditing: boolean + open: MockedFunction<(relPath: string) => Promise> + update: MockedFunction<(content: string, single: boolean) => Promise> + } + } + + const buildStreamTask = (taskId: string, instanceId: string): StreamTask => ({ + taskId, + instanceId, + abort: false, + abandoned: false, + cwd: "/mock/cwd", + once: vi.fn(), + off: vi.fn(), + ask: vi.fn().mockResolvedValue(undefined), + finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), + + providerRef: { deref: () => ({ getState: async () => ({ experiments: {} }) }) }, + diffViewProvider: { + editType: "create", + isEditing: true, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + }) + + const partialBlock = { + type: "tool_use", + name: "write_to_file", + partial: true, + params: { path: "src/demo.ts", content: "hello world" }, + } satisfies Record as unknown as Parameters<(typeof writeToFileTool)["handlePartial"]>[1] + + // The path only counts as stable once the same path has been seen twice, so a delta that + // should reach the ask has to start from an entry seeded by an earlier delta. + const seedStablePath = (task: Task) => { + const state = writeToFileTool["getTaskPartialStreamState"](task) + state.lastSeenPartialPath = "src/demo.ts" + return state + } + // A refused rollback plus an empty streamed document reaches handlePartial with an ask + // already on screen and no preview to open, which used to leave that ask partial forever. + it("finalizes the empty-content partial ask when a rollback was refused", async () => { + const task = buildStreamTask("refused-empty-content", "inst-c6") + seedStablePath(task as unknown as Task) + const failureKey = writeToFileTool["getPartialStreamFailureKey"](task as unknown as Task) + writeToFileTool["taskRollbackFailures"].set( + failureKey, + new Error("EPERM: cannot restore the streamed document"), + ) + const emptyContentBlock = { + ...partialBlock, + params: { path: "src/demo.ts", content: "" }, + } + + await writeToFileTool.handlePartial(task as unknown as Task, emptyContentBlock) + + expect(task.ask).toHaveBeenCalledWith("tool", expect.stringContaining('"content":""'), true) + expect(task.finalizePartialToolAsk).toHaveBeenCalledWith(expect.stringContaining('"content":""')) + expect(task.diffViewProvider.open).not.toHaveBeenCalled() + expect(task.diffViewProvider.update).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + writeToFileTool["taskRollbackFailures"].delete(failureKey) + }) + + it("does not register per-task state for a task that already stopped", async () => { + const task = buildStreamTask("cancel-before-register", "inst-c1") + task.abort = true + + await writeToFileTool.handlePartial(task as unknown as Task, partialBlock) + + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(task.once).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(task.diffViewProvider.open).not.toHaveBeenCalled() + }) + + it("releases the state and stops before the next boundary when the task aborts during getState()", async () => { + const task = buildStreamTask("cancel-in-getstate", "inst-c2") + // No editType yet, so the very next step after getState() would be the filesystem probe: + // asserting the probe never ran pins THIS boundary. (Leaving editType set would let the + // suppressed-focus branch release the state and return on its own, which proves nothing + // about the check under test.) + task.diffViewProvider.editType = undefined + seedStablePath(task as unknown as Task) + task.providerRef.deref = () => ({ + getState: async () => { + task.abort = true + return { experiments: {} } + }, + }) + + await writeToFileTool.handlePartial(task as unknown as Task, partialBlock) + + expect(vi.mocked(fileExistsAtPath)).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(task.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, expect.any(Function)) + expect(task.ask).not.toHaveBeenCalled() + }) + + it("releases the state and skips the partial ask when the task is abandoned during the file probe", async () => { + const task = buildStreamTask("cancel-in-probe", "inst-c3") + // No editType yet forces the filesystem probe, the await boundary between the setup and the + // first thing the user sees. + task.diffViewProvider.editType = undefined + seedStablePath(task as unknown as Task) + const probe = vi.mocked(fileExistsAtPath) + probe.mockImplementation(async () => { + task.abandoned = true + return false + }) + + await writeToFileTool.handlePartial(task as unknown as Task, partialBlock) + + expect(probe).toHaveBeenCalledTimes(1) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + expect(task.ask).not.toHaveBeenCalled() + }) + + it("does not open the diff view when the task aborts while the partial ask is in flight", async () => { + const task = buildStreamTask("cancel-in-ask", "inst-c4") + seedStablePath(task as unknown as Task) + task.ask = vi.fn().mockImplementation(async () => { + task.abort = true + return undefined + }) + + await writeToFileTool.handlePartial(task as unknown as Task, partialBlock) + + expect(task.ask).toHaveBeenCalledTimes(1) + expect(task.diffViewProvider.open).not.toHaveBeenCalled() + expect(task.diffViewProvider.update).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) +}) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 7cb9d01585..ce9a7bb5de 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" @@ -96,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 @@ -118,6 +132,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 +145,8 @@ describe("writeToFileTool", () => { return content }) + mockCline.taskId = "task-1" + mockCline.instanceId = "instance-1" mockCline.cwd = "/" mockCline.consecutiveMistakeCount = 0 mockCline.didEditFile = false @@ -150,7 +169,10 @@ describe("writeToFileTool", () => { open: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), reset: vi.fn().mockResolvedValue(undefined), - revertChanges: vi.fn().mockResolvedValue(undefined), + // A diff view that has an edit in progress: revertChanges() reports the rollback it + // performed. Tests that need the no-op case - an edit already cleared by reset() - return + // false instead. + revertChanges: vi.fn().mockResolvedValue(true), saveChanges: vi.fn().mockResolvedValue({ newProblemsMessage: "", userEdits: null, @@ -186,8 +208,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) @@ -288,14 +314,17 @@ describe("writeToFileTool", () => { ) it.skipIf(process.platform === "win32")( - "creates parent directories when path has stabilized (partial)", + "defers parent directory creation to execute() while streaming", async () => { - // First call - path not yet stabilized + // Streaming deltas must not touch the filesystem at all. An unguarded + // createDirectoriesForFile here threw EROFS up into BaseTool.handle(), which never + // set didRejectTool/didAlreadyUseTool, so the agent loop stalled permanently. + // The directories are still created - by the authoritative non-partial execute(). + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) await executeWriteFileTool({}, { fileExists: false, isPartial: true }) expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() - // Second call with same path - path is now stabilized - await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + await executeWriteFileTool({}, { fileExists: false }) expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) }, ) @@ -420,6 +449,583 @@ 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 + 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() + }) + + 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() + } + }) + + 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 released what the registration acquired. The error still has to surface, + // so the boundary rethrows and BaseTool.handle() reports it once. + 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", () => { + // 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, + rollbackFailure: 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("parse-failure reporting and early-return cleanup", () => { + it("reports the captured streaming failure once instead of the incidental parse error", async () => { + // A streaming delta hit a fatal filesystem error and the finalized block then + // arrives without nativeArgs: execute() never runs, so its authoritative retry of + // the same filesystem operation never happens either. The captured error is the one + // the user can act on, and it must surface exactly once. + const state = writeToFileTool["getTaskPartialStreamState"](mockCline as never) + // The stream opened a diff view: that is what makes the rollback meaningful. + mockCline.diffViewProvider.isEditing = true + state.streamFailed = true + const streamFailure = new Error("EACCES: stream open failed") + state.streamError = streamFailure + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledTimes(1) + expect(mockHandleError).toHaveBeenCalledWith("writing file", streamFailure) + expect(mockHandleError).not.toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + // The stream may have left a diff view open with content that was never approved. + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + }) + it("keeps the generic parse error when no per-task stream state exists", async () => { + // BaseTool.handle() delegates every missing-nativeArgs error to + // releaseStreamStateOnParseFailure() before reporting the parse error. With no prior + // partial delta there is no state to release: the hook must return false so the + // ordinary parse error still reaches the user - suppressing it here would silence + // every malformed write_to_file call in a task that never streamed. + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + // No nativeArgs: this drives BaseTool's parse-failure path. + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // Counted by context rather than toHaveBeenCalledWith: a count of matching calls + // cannot auto-pass when handleError fires more than once. + const parseCalls = mockHandleError.mock.calls.filter( + ([context]) => context === "parsing write_to_file args", + ) + const writeCalls = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(parseCalls).toHaveLength(1) + expect(writeCalls).toHaveLength(0) + expect(mockHandleError).toHaveBeenCalledTimes(1) + expect(parseCalls[0][1]).toBeInstanceOf(Error) + // The no-state branch returns before touching the diff view or registering anything. + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("releases the per-task stream state when execute() returns early on a denied path", async () => { + // The rooignore branch returns before execute()'s success/catch cleanup; without + // this the abort listener and the streamFailed guard outlive the call and suppress + // the diff preview of every later write_to_file in this task. + writeToFileTool["getTaskPartialStreamState"](mockCline as never).streamFailed = true + + await executeWriteFileTool({}, { accessAllowed: false }) + + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + // The exact listener this task registered, not just any function: a mismatched + // off() argument would leave the real listener attached. + const abortListener = mockCline.once.mock.calls.find( + ([event]: unknown[]) => event === RooCodeEventName.TaskAborted, + )?.[1] + expect(abortListener).toBeInstanceOf(Function) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + }) + + it("releases the per-task stream state when the rooignore ask itself rejects", async () => { + // task.say() can reject when the task is cancelled or disposed mid-ask. The release sits + // in a finally, so the stream state and its TaskAborted listener still go away even though + // the ask threw; without it the streamFailed guard suppresses every later diff preview in + // this task. + writeToFileTool["getTaskPartialStreamState"](mockCline as never).streamFailed = true + mockCline.say = vi.fn().mockRejectedValue(new Error("task cancelled during the rooignore ask")) + + await expect(executeWriteFileTool({}, { accessAllowed: false })).rejects.toThrow( + "task cancelled during the rooignore ask", + ) + + 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) + // The ask threw, so the denial result was never pushed: the release cannot depend on it. + expect(mockPushToolResult).not.toHaveBeenCalled() + }) + + it("does not revert the diff view when the parse-failure teardown has no edit in progress", async () => { + // One unstabilized delta registers this task's stream state without ever opening a diff + // view; the finalized block then arrives without nativeArgs. DiffViewProvider keeps the + // PREVIOUS edit's relPath, so reverting here would roll back - and for a new file delete - + // a file this write never opened. The teardown still resets the view and releases state. + await executeWriteFileTool({}, { isPartial: true }) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + mockCline.diffViewProvider.isEditing = false + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + // No nativeArgs: this drives BaseTool's parse-failure path. + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCline.diffViewProvider.revertChanges).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("releases the per-task stream state when the path is missing", async () => { + // The missing-path return sits before execute()'s guarded scope, so it needs its own + // release. The state is seeded first so the assertion proves a release happened rather + // than an empty map. + writeToFileTool["getTaskPartialStreamState"](mockCline as never) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + + const toolUse = { + type: "tool_use", + name: "write_to_file", + params: { content: testContent }, + nativeArgs: { path: "", content: testContent }, + // An empty path is what the streaming parser produces for a not-yet-complete + // argument object; the typed params cannot express it - hence the double assertion. + partial: false, + } as unknown as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, toolUse, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCline.recordToolError).toHaveBeenCalledWith("write_to_file") + 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) + }) + + it("releases the per-task stream state when content is missing", async () => { + writeToFileTool["getTaskPartialStreamState"](mockCline as never) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(1) + + const toolUse = { + type: "tool_use", + name: "write_to_file", + params: { path: testFilePath }, + nativeArgs: { path: testFilePath, content: undefined }, + // The fixture's point is a nativeArgs object whose content never arrived, which the + // typed params cannot express - hence the double assertion. + partial: false, + } as unknown as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, toolUse, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("write_to_file", "content") + 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) + }) + }) + describe("per-task stream state isolation", () => { + // A second task streaming through the same singleton while mockCline runs. + // Structural double, same pattern as the partial-state-cleanup spec. + function buildStreamingTask(taskId: string, instanceId: string) { + return { + taskId, + instanceId, + once: vi.fn(), + off: vi.fn(), + diffViewProvider: { + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + }, + finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined), + } + } + + it("leaves another task's stream state intact when execute() completes", async () => { + const other = buildStreamingTask("task-2", "instance-2") + const otherState = writeToFileTool["getTaskPartialStreamState"](other as never) + otherState.streamFailed = true + otherState.streamError = new Error("other task stream failure") + + await executeWriteFileTool({}) + + // The other task is still streaming: its failure state must survive, or its + // next delta re-opens the diff view and spawns a duplicate partial ask. + const retained = writeToFileTool["taskPartialStreamState"].get("task-2.instance-2") + expect(retained).toBeDefined() + expect(retained?.streamFailed).toBe(true) + expect(retained?.streamError?.message).toBe("other task stream failure") + expect(other.off).not.toHaveBeenCalled() + }) + + it("finalizes the partial ask when the write itself fails", async () => { + mockCline.diffViewProvider.saveChanges.mockRejectedValue(new Error("save failed")) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + + // The diff-view branch opened a partial ask for this write; without the + // finalize the spinner and Save/Reject stay live after the failure. + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) + }) + + it("releases this task's stream state when the completed block fails to parse", async () => { + // A streaming delta had failed, so the guard is set; the final block then + // arrives without nativeArgs, so execute() never runs. + const state = writeToFileTool["getTaskPartialStreamState"](mockCline as never) + state.streamFailed = true + const other = buildStreamingTask("task-2", "instance-2") + writeToFileTool["getTaskPartialStreamState"](other as never).streamFailed = true + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + // Otherwise the retained streamFailed suppresses the diff preview of every + // later write_to_file in this task. + expect(writeToFileTool["taskPartialStreamState"].has(`${mockCline.taskId}.${mockCline.instanceId}`)).toBe( + false, + ) + // ...and the cleanup must stay scoped: the other task is still streaming. + expect(writeToFileTool["taskPartialStreamState"].get("task-2.instance-2")?.streamFailed).toBe(true) + }) + it("releases this task's stream state when prevent-focus-disruption skips the partial preview", async () => { + // handlePartial() registers the per-task entry (and its TaskAborted listener) before it + // checks the experiment. With the experiment on, the delta returns without ever opening a + // preview and never reaches execute()'s teardown, so the entry and the listener would stay + // attached for the rest of the task's life - and a streamFailed mark armed by an earlier + // delta would keep suppressing this task's later diff previews. + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + const delta = (content: string) => + writeToFileTool.handle( + mockCline, + { + type: "tool_use", + name: "write_to_file", + params: { path: testFilePath, content }, + nativeArgs: { path: testFilePath, content }, + partial: true, + } as ToolUse<"write_to_file">, + { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }, + ) + + // The first delta only pins the path; the second is the one that reaches the check. + await delta("Line 1") + await delta("Line 1\nLine 2") + + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + // The exact listener this task registered, not just any function: a mismatched + // off() argument would leave the real listener attached. + const abortListener = mockCline.once.mock.calls.find( + ([event]: unknown[]) => event === RooCodeEventName.TaskAborted, + )?.[1] + expect(abortListener).toBeInstanceOf(Function) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + }) + + it("releases the per-task stream state when the user rejects the diff-view approval", async () => { + // The diff-view denial returns from inside execute()'s try. Without a release on that + // path the entry and its TaskAborted listener survive a rejected write, and a + // streamFailed mark armed by an earlier delta keeps suppressing this task's later + // diff previews. + writeToFileTool["getTaskPartialStreamState"](mockCline as never).streamFailed = true + mockAskApproval.mockResolvedValueOnce(false) + + await executeWriteFileTool({}) + + expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + // The exact listener this task registered, not just any function: a mismatched + // off() argument would leave the real listener attached. + const abortListener = mockCline.once.mock.calls.find( + ([event]: unknown[]) => event === RooCodeEventName.TaskAborted, + )?.[1] + expect(abortListener).toBeInstanceOf(Function) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + }) + + it("releases the per-task stream state when the prevent-focus-disruption approval is rejected", async () => { + // Same leak on the experiment branch: the denial returns without a teardown, so the + // listener stays attached for the rest of the task's life. + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + writeToFileTool["getTaskPartialStreamState"](mockCline as never).streamFailed = true + mockAskApproval.mockResolvedValueOnce(false) + + await executeWriteFileTool({}) + + expect(mockAskApproval).toHaveBeenCalled() + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + // The exact listener this task registered, not just any function: a mismatched + // off() argument would leave the real listener attached. + const abortListener = mockCline.once.mock.calls.find( + ([event]: unknown[]) => event === RooCodeEventName.TaskAborted, + )?.[1] + expect(abortListener).toBeInstanceOf(Function) + expect(mockCline.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, abortListener) + }) + + it("releases the per-task stream state when the preflight directory creation throws", async () => { + // The preflight filesystem work sits before execute()'s guarded scope today: when it + // throws, nothing reports the failure and the stream state leaks. It has to be handled + // like any other write failure - reported once, teardown run. + writeToFileTool["getTaskPartialStreamState"](mockCline as never).streamFailed = true + mockedCreateDirectoriesForFile.mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied, mkdir '/new-parent'"), { code: "EACCES" }), + ) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) }) describe("user interaction", () => { @@ -461,16 +1067,485 @@ 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(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("keeps a failed rollback as the stream failure instead of dropping it", async () => { + // Streaming failed (open() rejected) and the rollback that follows failed too: the + // placeholder and any created directories are still on disk. Logging the revert failure + // and dropping it leaves the next execute() to treat that debris as an existing file, so + // the rollback failure has to become the failure this stream reports. + mockCline.diffViewProvider.open.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, open '/ro/test.py'"), { code: "EACCES" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValue( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + + // First delta pins the path, second reaches open(). + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + + const state = writeToFileTool["taskPartialStreamState"].get(`${mockCline.taskId}.${mockCline.instanceId}`) + expect(state?.streamFailed).toBe(true) + expect(state?.streamError?.message).toContain("rollback failed") + // The original streaming error must stay reachable behind the reported one. + expect((state?.streamError?.cause as Error | undefined)?.message).toBe( + "EACCES: permission denied, open '/ro/test.py'", + ) + }) + + it("fails closed without retrying any write path when the streaming rollback was refused", async () => { + // update() fails mid-stream and the rollback that follows is refused: the dirty + // streamed buffer survives reset(), which cannot close a dirty diff tab. The next + // execute() would call open() again, and open() saves a dirty existing document + // BEFORE approval - persisting content the user never approved. execute() must fail + // closed on the recorded rollback failure instead of retrying. + mockCline.diffViewProvider.update.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValue( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + + // First delta pins the path, second reaches update() and hits the refused rollback. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + + const state = writeToFileTool["taskPartialStreamState"].get(`${mockCline.taskId}.${mockCline.instanceId}`) + const recorded = state?.rollbackFailure + expect(recorded).toBeInstanceOf(Error) + expect(recorded?.message).toContain("rollback failed") + // The original streaming error stays reachable behind the reported failure. + expect((recorded?.cause as Error | undefined)?.message).toBe( + "EROFS: read-only file system, write '/ro/test.py'", + ) + + // The valid final block arrives: execute() must not touch any write path. + await executeWriteFileTool({}) + + // Only the delta's own open()/update() ran; execute() never re-opened the diff view, + // so the dirty buffer could not be saved and no file write or directory creation + // happened - and no approval was even requested. + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.update).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() + expect(mockAskApproval).not.toHaveBeenCalled() + expect(mockPushToolResult).not.toHaveBeenCalled() + + // The recorded failure is the single report (counted, not toHaveBeenCalledWith), + // reported with its identity, and the per-task state is released. + const writeCalls = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(writeCalls).toHaveLength(1) + expect(writeCalls[0][1]).toBe(recorded) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("keeps the refused-rollback guard across an API request that clears the task state", async () => { + mockCline.diffViewProvider.update.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValue( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + + // First delta pins the path, second reaches update() and hits the refused rollback. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + + // Task clears the per-stream state at the start of EVERY API request; a refused + // rollback left the dirty buffer in place, so the guard must survive that clear. + writeToFileTool.clearTaskState(mockCline) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + + await executeWriteFileTool({}) + + // Still fail closed: no second open(), no approval, one report. + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.update).toHaveBeenCalledTimes(1) + expect(mockAskApproval).not.toHaveBeenCalled() + const writeCalls = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(writeCalls).toHaveLength(1) + expect(writeCalls[0][1]).toBeInstanceOf(Error) + // The rollback is still refused, so the dirty buffer remains: the marker stays set + // to keep the next write fail-closed until a rollback succeeds. + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + }) + + it("keeps the refused-rollback marker when revertChanges() resolves without restoring anything", async () => { + // execute() tries the restore once more before it reports the refusal. revertChanges() + // resolves without touching the document when this provider no longer has an edit in + // progress - exactly the state reset() leaves behind after a refused restore - so a resolved + // promise is not evidence that the dirty buffer came back. Treating it as one clears the + // marker, and the next write to this path calls open(), which saves the unapproved streamed + // content before approval. + mockCline.diffViewProvider.update.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + // The stream's rollback is refused; execute()'s retry resolves having done nothing. + mockCline.diffViewProvider.revertChanges + .mockRejectedValueOnce(Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" })) + .mockResolvedValueOnce(false) + + // First delta pins the path, second hits the refused rollback, then the final block arrives. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}) + + // Still fail closed: no second open(), no approval asked, one report, marker kept. + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockAskApproval).not.toHaveBeenCalled() + expect(mockPushToolResult).not.toHaveBeenCalled() + const writeCalls = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(writeCalls).toHaveLength(1) + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + }) + + it("releases the refused-rollback marker when the retried rollback restores the buffer", async () => { + // The other half of the contract: a rollback that reports completion lifts the refusal, and + // the following write may open the diff view again. + mockCline.diffViewProvider.update.mockRejectedValue( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges + .mockRejectedValueOnce(Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" })) + .mockResolvedValueOnce(true) + + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}) + + expect(writeToFileTool["taskRollbackFailures"].size).toBe(0) + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(1) + const writeCalls = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(writeCalls).toHaveLength(1) + + // The next write is no longer refused: the buffer is back, so the preview may open again. + mockCline.diffViewProvider.update.mockResolvedValue(undefined) + await executeWriteFileTool({}) + expect(mockCline.diffViewProvider.open).toHaveBeenCalledTimes(2) + expect(mockAskApproval).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("finalizes the partial tool ask when a refused rollback suppresses the preview", async () => { + // A parse-failure teardown with a failed rollback sets the refused-rollback marker. The next + // partial delta of this request streams its tool ask before it reaches that guard, and then + // returns without ever opening the diff view: the ask has to be finalized here, or the + // spinner and Save/Reject buttons stay live for a preview that will never be shown. + await executeWriteFileTool({}, { isPartial: true }) + + mockCline.diffViewProvider.isEditing = true + mockCline.diffViewProvider.revertChanges.mockRejectedValue( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + await writeToFileTool.releaseStreamStateOnParseFailure(mockCline, { handleError: mockHandleError }) + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + + // The teardown released this task's stream state, so the next deltas re-register it: one to + // pin the path, one to stream the ask and hit the guard. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + + // The ask was streamed, then finalized with the exact payload it was opened with. + expect(mockCline.ask).toHaveBeenCalledWith("tool", expectedPartialToolMessage, true) + expect(mockCline.finalizePartialToolAsk).toHaveBeenCalledWith(expectedPartialToolMessage) + // No preview was opened, and execute() still owns the refusal. + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("reports only the execute() failure when the write is retried after a failed stream", async () => { + // Combined path: a streaming delta failed (the failure is captured, not reported), then + // the completed block arrives with valid nativeArgs, so execute() runs and retries the + // same operation. Its failure is the single failure the user hears about - the captured + // streaming error must not also surface, or the same write reports twice. + mockCline.diffViewProvider.open.mockRejectedValue( + Object.assign(new Error("EACCES: permission denied, open '/ro/test.py'"), { code: "EACCES" }), + ) + + // First delta pins the path, second reaches open() and captures the failure. + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + expect(mockHandleError).not.toHaveBeenCalled() + const state = writeToFileTool["taskPartialStreamState"].get(`${mockCline.taskId}.${mockCline.instanceId}`) + expect(state?.streamFailed).toBe(true) + expect(state?.streamError?.message).toBe("EACCES: permission denied, open '/ro/test.py'") + + // The completed block: open() works now, the write itself fails. + mockCline.diffViewProvider.open.mockResolvedValue(undefined) + mockCline.diffViewProvider.saveChanges.mockRejectedValue(new Error("EROFS: read-only file system, write")) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledTimes(1) + expect(mockHandleError).toHaveBeenCalledWith( + "writing file", + expect.objectContaining({ message: "EROFS: read-only file system, write" }), + ) + expect(mockHandleError).not.toHaveBeenCalledWith( + "writing file", + expect.objectContaining({ message: "EACCES: permission denied, open '/ro/test.py'" }), + ) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("reports the rollback failure once when the parse-failure cleanup cannot revert the diff", async () => { + // A streaming delta captured a fatal error; the finalized block then fails to parse, so + // the parse-failure cleanup runs - and its revertChanges() fails too. The rollback + // failure is the actionable one: it must be reported exactly once, with the captured + // streaming error kept as its cause, and the incidental parse error must stay silent. + mockCline.diffViewProvider.open.mockRejectedValue(new Error("EACCES: stream open failed")) + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + const state = writeToFileTool["taskPartialStreamState"].get(`${mockCline.taskId}.${mockCline.instanceId}`) + const captured = state?.streamError + expect(captured?.message).toBe("EACCES: stream open failed") + + // The stream had a diff view open - that is the state a rollback is for. + mockCline.diffViewProvider.isEditing = true + mockCline.diffViewProvider.revertChanges.mockRejectedValue(new Error("EACCES: rollback failed")) + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledTimes(1) + const [context, reported] = mockHandleError.mock.calls[0] + expect(context).toBe("writing file") + expect(reported.message).toContain("rollback failed") + expect(reported.cause).toBe(captured) + expect(mockHandleError).not.toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("reports the captured streaming error once when two real deltas fail and the rollback succeeds", async () => { + // The capture-to-report path end to end: two real partial deltas with open() rejecting, + // then the completed block without nativeArgs so execute() never runs and + // onParameterParseFailure() is the only reporter. The rollback succeeds, so the captured + // streaming error - not the incidental parse error - must reach the user exactly once. + mockCline.diffViewProvider.open.mockRejectedValue(new Error("EACCES: stream open failed")) + await executeWriteFileTool({}, { isPartial: true }) + await executeWriteFileTool({}, { isPartial: true }) + const state = writeToFileTool["taskPartialStreamState"].get(`${mockCline.taskId}.${mockCline.instanceId}`) + const captured = state?.streamError + expect(captured?.message).toBe("EACCES: stream open failed") + + // The stream left a diff view open; revertChanges() keeps its resolved default, so the + // rollback succeeds and nothing re-stamps the captured error. + mockCline.diffViewProvider.isEditing = true + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledTimes(1) + // Identity, not just text: the report must be the original streaming error object. + expect(mockHandleError.mock.calls[0][0]).toBe("writing file") + expect(mockHandleError.mock.calls[0][1]).toBe(captured) + expect(mockHandleError).not.toHaveBeenCalledWith("parsing write_to_file args", expect.any(Error)) + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + it("reports a rollback failure under its own message and keeps the parse error when no stream failed", async () => { + // The diff view opened without any streaming failure; the completed block then fails to + // parse and the rollback itself fails. Reporting "after a streaming error" would describe + // a failure that never happened and would swallow the malformed tool call, so the rollback + // reports under its own message and the parse error is still delivered. + writeToFileTool["getTaskPartialStreamState"](mockCline as never) + mockCline.diffViewProvider.isEditing = true + mockCline.diffViewProvider.revertChanges.mockRejectedValue(new Error("EACCES: rollback failed")) + + const block = { + type: "tool_use", + name: "write_to_file", + params: {}, + partial: false, + } as ToolUse<"write_to_file"> + await writeToFileTool.handle(mockCline, block, { + askApproval: mockAskApproval, + toolCallId: "test-tool-call-id", + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledTimes(2) + const [context, reported] = mockHandleError.mock.calls[0] + expect(context).toBe("writing file") + expect(reported.message).toBe("write_to_file rollback failed: EACCES: rollback failed") + expect(reported.message).not.toContain("streaming error") + expect(reported.cause).toBeInstanceOf(Error) + // The parse error is the actionable report for the model: it must still be delivered. + expect(mockHandleError.mock.calls[1][0]).toBe("parsing write_to_file args") + expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) + }) + + 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 }) - expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) + + // 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() }) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6cec3bdbe7..aa4cab38ed 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -77,6 +77,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, @@ -728,6 +729,11 @@ export class ClineProvider removeTaskEventListeners(this.taskEventListeners, 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) { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 3c1d33ae28..b7b41e3f75 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -21,6 +21,11 @@ import { Task } from "../../core/task/Task" import { DecorationController } from "./DecorationController" +/** Narrow ENOENT test so a rollback can tolerate a file or dir that never landed. */ +function isEnoent(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "ENOENT" +} + export const DIFF_VIEW_URI_SCHEME = "cline-diff" export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Zoo's Changes" @@ -513,13 +518,51 @@ export class DiffViewProvider { return JSON.stringify(result) } - async revertChanges(): Promise { - if (!this.relPath || !this.activeDiffEditor) { - return + /** + * Remove a file this edit created. Tolerates ENOENT: when open() failed before the + * placeholder was written (or the write itself failed) there is nothing to delete, and + * that must not abort the rollback. + */ + private async removeCreatedFile(absolutePath: string): Promise { + try { + await fs.unlink(absolutePath) + } catch (error: unknown) { + if (!isEnoent(error)) { + throw error + } + } + } + + /** Same tolerance for a directory this edit created that never made it to disk. */ + private async removeCreatedDir(dirPath: string): Promise { + try { + await fs.rmdir(dirPath) + } catch (error: unknown) { + if (!isEnoent(error)) { + throw error + } + } + } + + /** + * Rolls the streamed edit back and reports whether a rollback was actually performed. + * + * The boolean is the caller's evidence: a resolved promise alone cannot tell a fail-closed caller + * whether the dirty buffer was restored or whether this provider simply had no edit in progress to + * restore. WriteToFileTool keeps its refused-rollback marker on the false return. + */ + async revertChanges(): Promise { + // Both guards are required. relPath survives a completed edit, so without isEditing a later + // teardown would take the new-file branch for the PREVIOUS edit's target and unlink an + // existing user file - permanently. isEditing alone is not enough: open() sets it before the + // first await, and that is exactly the state a failed open() leaves behind, where the + // placeholder and the created directories still have to be rolled back. + if (!this.relPath || !this.isEditing) { + // Nothing to restore, and nothing was restored. + return false } const fileExists = this.editType === "modify" - const updatedDocument = this.activeDiffEditor.document const absolutePath = path.resolve(this.cwd, this.relPath) // Stop tracking touches and cancel any pending scroll-to-diff before any @@ -528,36 +571,57 @@ export class DiffViewProvider { this.cancelDeferredScroll() if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() + // open() creates the parent directories and an empty placeholder file BEFORE it + // awaits openDiffEditor(). If that await rejects there is no activeDiffEditor, and + // the previous early return here left the placeholder and the new directories on + // disk: the next execute() then saw an empty file and treated the requested new file + // as an existing one, so a denial preserved the debris. The filesystem rollback runs + // either way; only the document work needs an editor. + if (this.activeDiffEditor) { + const updatedDocument = this.activeDiffEditor.document + if (updatedDocument.isDirty) { + // The buffer holds the streamed content of a write that was never approved. Saving + // it here persisted exactly what this rollback is undoing - local history, file + // watchers, and, if the delete below fails, the content itself. Discard the buffer + // (force-close the tab) instead; the file goes away a moment later anyway. + await this.discardFileTab(absolutePath) + await this.closeAllDiffViews() + } else { + await this.closeAllDiffViews() + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) + } } - await this.closeAllDiffViews() - // The file was newly created for this edit; close its transiently - // opened tab before deleting it from disk. - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) + await this.removeCreatedFile(absolutePath) // Remove only the directories we created, in reverse order. for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) + await this.removeCreatedDir(this.createdDirs[i]) } } else { - // Revert document. - const edit = new vscode.WorkspaceEdit() - - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length), - ) - - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + // Only reachable after a successful open(), so the editor exists. + const updatedDocument = this.activeDiffEditor?.document + if (!updatedDocument) { + // The editor vanished: the existing file was left exactly as the stream left it. + return false + } - // Apply the edit and save, since contents shouldn't have changed - // this won't show in local history unless of course the user made + // Revert the document through the same contract the new-file rollback uses, so a + // refused restore cannot be followed by a save here either. The document is passed + // explicitly: this branch reached it through activeDiffEditor, and resolving it again + // by path could silently pick a different buffer - or none, which would skip the + // restore entirely. + // + // Applying and saving the restore does not show in local history unless the user made // changes and saved during the edit. - await vscode.workspace.applyEdit(edit) - await updatedDocument.save() + await this.restorePreStreamBuffer( + absolutePath, + updatedDocument, + this.stripAllBOMs(this.originalContent ?? ""), + ) + await this.saveBufferClean(absolutePath, updatedDocument) await this.closeAllDiffViews() @@ -582,6 +646,7 @@ export class DiffViewProvider { // Edit is done. await this.reset() + return true } private async closeAllDiffViews(): Promise { @@ -847,6 +912,121 @@ export class DiffViewProvider { // Close the plain (non-diff) editor tab for the target file. Used when the // file was opened transiently for the diff and the user never interacted // with it, so it should not linger after accept/deny. + /** + * Close the tab for this path WITHOUT saving. Used by the new-file rollback, where the + * buffer holds unapproved streamed content that must never reach disk; closeFileTab() + * deliberately skips dirty tabs, so a dirty buffer needs the forced close. + * + * A close that fails or is refused is a rollback failure: the buffer would still hold the + * content the user never approved, one save away from disk. Put the pre-stream content back + * so nothing saveable survives, and propagate - the caller must not keep deleting around a + * buffer it could not discard. + */ + private async discardFileTab(absolutePath: string): Promise { + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter( + (tab) => + tab.input instanceof vscode.TabInputText && + tab.input.uri.scheme === "file" && + arePathsEqual(tab.input.uri.fsPath, absolutePath), + ) + + // Restore and save once, before the loop and independent of it. The document can be open + // only through the diff editor - the user may have closed the plain text tab, leaving + // `tabs` empty - and in that shape the loop never ran, so the streamed buffer stayed + // dirty: closeAllDiffViews() skips dirty tabs and removeCreatedFile() then unlinks the + // file under an unsaved buffer that a later save recreates with the content this rollback + // was meant to discard. Hoisting also stops a multi-tab path from restoring and saving the + // same document once per tab. + await this.restorePreStreamBuffer(absolutePath) + await this.saveBufferClean(absolutePath) + + for (const tab of tabs) { + // tabGroups.close()'s second argument is preserveFocus, not a force-discard flag: a + // dirty tab prompts or is refused, which is how unapproved streamed content survived a + // "forced" close. The buffer was restored and saved clean above, so close() never sees + // a dirty tab and nothing unapproved reaches disk. + let closed: boolean + let closeError: Error | undefined + try { + closed = await vscode.window.tabGroups.close(tab) + } catch (error) { + closed = false + closeError = error instanceof Error ? error : new Error(String(error)) + } + if (!closed) { + throw new Error( + `Rollback could not close the restored buffer for ${absolutePath}; its content was put back to the pre-stream state but the tab is still open.`, + { cause: closeError }, + ) + } + } + } + + /** + * Replace an open buffer's content with what it held before streaming started. Runs before + * the rollback closes the tab, so an unapproved buffer is never handed to close() dirty and + * never survives a close that the editor vetoes. + * + * A refused WorkspaceEdit is a failed restore, not a no-op: the buffer still holds content the + * user never approved. Throwing is this method's own contract rather than a caller policy - + * the save that follows cannot run, so nothing saveable survives, and the caller reports the + * rollback as failed instead of telling the user the write was rolled back with unapproved + * content still one save from disk. Returning quietly would be worse than returning false: + * it would swallow the failure. + */ + private async restorePreStreamBuffer( + absolutePath: string, + document?: vscode.TextDocument, + content?: string, + ): Promise { + const target = + document ?? + vscode.workspace.textDocuments.find( + (document) => document.uri.scheme === "file" && arePathsEqual(document.uri.fsPath, absolutePath), + ) + if (!target) { + return + } + const edit = new vscode.WorkspaceEdit() + const range = new vscode.Range(target.positionAt(0), target.positionAt(target.getText().length)) + edit.replace(target.uri, range, content ?? this.originalContent ?? "") + const restored = await vscode.workspace.applyEdit(edit) + if (!restored) { + throw new Error( + `Rollback could not restore the streamed buffer for ${absolutePath}; the editor refused the restore, so the unapproved content is still in the buffer and unsaved.`, + ) + } + } + + /** + * Save a restored buffer so it is clean when the rollback closes it. close() has no + * force-discard parameter, so a dirty tab would prompt or be refused; saving the restored + * content is what makes the close unconditional. For a new file the placeholder is unlinked + * a moment later, so the saved content is transient by design. + * + * Only ever reached after a successful restore: a refused restore throws above, because a + * buffer whose restore failed still holds the unapproved content this rollback discards and + * must never be saved. + */ + private async saveBufferClean(absolutePath: string, document?: vscode.TextDocument): Promise { + const target = + document ?? + vscode.workspace.textDocuments.find( + (document) => document.uri.scheme === "file" && arePathsEqual(document.uri.fsPath, absolutePath), + ) + if (!target?.isDirty) { + return + } + const saved = await target.save() + if (!saved) { + throw new Error( + `Rollback could not save the restored buffer for ${absolutePath}; the editor did not persist the restored content, so the buffer is still dirty and one save away from holding what this rollback undid.`, + ) + } + } + private async closeFileTab(absolutePath: string): Promise { const tabs = vscode.window.tabGroups.all .flatMap((group) => group.tabs) @@ -1110,6 +1290,7 @@ export class DiffViewProvider { this.cancelDeferredScroll() await this.closeAllDiffViews() + this.relPath = undefined this.editType = undefined this.isEditing = false this.originalContent = undefined diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e94a66e1db..b980733db0 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 { DecorationController } from "../DecorationController" import type { Task } from "../../../core/task/Task" @@ -18,6 +19,10 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + // revertChanges() rolls a new-file edit back by unlinking the placeholder and rmdir-ing the + // directories the edit created, so those two need real doubles here or the rollback throws. + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -318,6 +323,24 @@ describe("DiffViewProvider", () => { { preserveFocus: true }, ) }) + it("should handle showTextDocument failure", async () => { + // Mock showTextDocument to fail + vi.mocked(vscode.window.showTextDocument).mockRejectedValue(new Error("Cannot open file")) + + // Mock workspace.onDidOpenTextDocument + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockReturnValue({ dispose: vi.fn() }) + + // Mock window.onDidChangeVisibleTextEditors + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + + // Set up for file + diffViewProvider["editType"] = "modify" + + // Try to open and expect rejection + await expect(diffViewProvider.open("test.md")).rejects.toThrow( + "Failed to execute diff command for /mock/cwd/test.md: Cannot open file", + ) + }) it.each([new Error("Cannot open file"), "Cannot open file"])( "should handle showTextDocument failure: %s", @@ -1060,6 +1083,10 @@ describe("DiffViewProvider", () => { vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + // Self-contained: the unit moved revertChanges()'s early-return guard from !activeDiffEditor + // to !isEditing, so this fixture has to declare an edit in progress on its own target. + diffViewProvider["relPath"] = "test.txt" + diffViewProvider["isEditing"] = true diffViewProvider["documentWasOpen"] = true diffViewProvider["preEditScrollLine"] = 15 diffViewProvider.editType = "modify" @@ -1086,12 +1113,14 @@ describe("DiffViewProvider", () => { describe("userTouchedDocument close/keep behavior", () => { const mockTargetPath = `${mockCwd}/mock-target-file.ts` - const buildActiveDiffEditor = () => + const buildActiveDiffEditor = (docOverrides: { isDirty?: boolean } = {}) => makeTextEditor({ document: makeTextDocument({ uri: makeUri(mockTargetPath), getText: vi.fn().mockReturnValue("content"), positionAt: vi.fn().mockReturnValue(makePosition()), + isDirty: docOverrides.isDirty ?? false, + ...docOverrides, }), }) @@ -1129,6 +1158,649 @@ describe("DiffViewProvider", () => { expect(closeFileTab).not.toHaveBeenCalled() expect(vscode.window.showTextDocument).toHaveBeenCalled() }) + it("revertChanges() neither saves nor deletes when the editor refuses the restore", async () => { + // A refused WorkspaceEdit leaves the unapproved streamed content in the buffer. Saving it + // would persist exactly what this rollback exists to undo, and deleting the placeholder + // underneath would report a rollback that never finished. The refusal has to reach the + // caller that reports failures, which is what the user gets instead of a false success. + const editor = buildActiveDiffEditor({ isDirty: true }) + editor.document.getText = vi.fn().mockReturnValue("streamed content") + const dirtyTab = { + input: Object.assign(new vscode.TabInputText(makeUri(mockTargetPath)), { + uri: makeUri(mockTargetPath), + }), + isDirty: true, + label: "mock-target-file.ts", + } + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [dirtyTab] }], + configurable: true, + }) + Object.defineProperty(vscode.workspace, "textDocuments", { + get: () => [editor.document], + configurable: true, + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(false) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [], + originalContent: "pre-stream content", + }) + + try { + await expect(diffViewProvider.revertChanges()).rejects.toThrow("could not restore the streamed buffer") + } finally { + if (originalTabs) { + Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + } + + // Nothing saveable was left behind, and nothing was closed or deleted on top of the refusal. + expect(editor.document.save).not.toHaveBeenCalled() + expect(vscode.window.tabGroups.close).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + }) + it("revertChanges() restores and saves the buffer through the real rollback path", async () => { + // The two tests above spy restorePreStreamBuffer() and saveBufferClean(), so the restoration + // itself never runs there and only proves ordering. This one leaves both real: the buffer must + // be put back through a WorkspaceEdit and saved clean, which is what stops close() from ever + // seeing a dirty tab holding content the user never approved. + const editor = buildActiveDiffEditor({ isDirty: true }) + editor.document.getText = vi.fn().mockReturnValue("streamed content") + const dirtyTab = { + input: Object.assign(new vscode.TabInputText(makeUri(mockTargetPath)), { + uri: makeUri(mockTargetPath), + }), + isDirty: true, + label: "mock-target-file.ts", + } + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [dirtyTab] }], + configurable: true, + }) + Object.defineProperty(vscode.workspace, "textDocuments", { + // The real restore and save look the document up here; without this the methods would find + // nothing and pass without touching anything. + get: () => [editor.document], + configurable: true, + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [], + originalContent: "pre-stream content", + }) + + try { + await diffViewProvider.revertChanges() + } finally { + // Do not leak the fixtures: later tests read the module-level tabGroups.all and textDocuments. + if (originalTabs) { + Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + } + + // The real restore replaced the whole buffer with the pre-stream content. + expect(vscode.workspace.applyEdit).toHaveBeenCalledTimes(1) + expect(mockWorkspaceEdit.replace).toHaveBeenCalledWith( + editor.document.uri, + expect.anything(), + "pre-stream content", + ) + // The real save ran on the restored buffer, and only then was the tab closed. + expect(editor.document.save).toHaveBeenCalledTimes(1) + const saveOrder = vi.mocked(editor.document.save).mock.invocationCallOrder[0] + const closeOrder = vi.mocked(vscode.window.tabGroups.close).mock.invocationCallOrder.at(-1) + expect(closeOrder).toBeGreaterThan(saveOrder) + expect(fs.unlink).toHaveBeenCalledWith(mockTargetPath) + }) + it("revertChanges() reports a close that rejected instead of deleting underneath it", async () => { + // tabGroups.close() can reject, not only resolve false (the editor throws on veto). The rejection + // used to be logged and swallowed, so the rollback continued, unlinked the placeholder, and + // removed the directories it had created underneath a tab that was still open. The rejection + // must surface as the rollback failure with the original error kept as its cause, and the + // filesystem cleanup must not run: after a refused discard the caller owns the next step + // (fail closed), so this rollback must not delete around it. + const editor = buildActiveDiffEditor({ isDirty: true }) + const closeError = new Error("Tab close rejected by the editor") + const dirtyTab = { + input: Object.assign(new vscode.TabInputText(makeUri(mockTargetPath)), { + uri: makeUri(mockTargetPath), + }), + isDirty: true, + label: "mock-target-file.ts", + } + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + const originalClose = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "close") + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [dirtyTab] }], + configurable: true, + }) + Object.defineProperty(vscode.workspace, "textDocuments", { + get: () => [editor.document], + configurable: true, + }) + Object.defineProperty(vscode.window.tabGroups, "close", { + value: vi.fn().mockRejectedValue(closeError), + configurable: true, + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [`${mockCwd}/new-parent`], + originalContent: "", + }) + + try { + // The failure is reported, not swallowed: revertChanges() rejects with the rollback + // error, and the original rejection stays reachable as its cause. + const failure = await diffViewProvider.revertChanges().then( + () => undefined, + (error: unknown) => (error instanceof Error ? error : new Error(String(error))), + ) + expect(failure).toBeInstanceOf(Error) + expect(failure?.message).toContain("could not close the restored buffer") + expect(failure?.cause).toBe(closeError) + // Cleanup ownership is retained: the close was attempted without a force flag, and + // neither the placeholder unlink nor the created-directory rmdir ran underneath the + // still-open tab. + expect(vscode.window.tabGroups.close).toHaveBeenCalledWith(dirtyTab) + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + } finally { + if (originalTabs) { + Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + if (originalClose) { + Object.defineProperty(vscode.window.tabGroups, "close", originalClose) + } + } + }) + it("revertChanges() reports a close the editor refused instead of deleting underneath it", async () => { + // Even a restored, saved buffer can fail to close (a vetoing editor). Swallowing that let + // the rollback delete the file underneath an open tab. The failure must propagate, and + // the file must survive: unlinking after a refused close leaves an open tab on a ghost. + const editor = buildActiveDiffEditor({ isDirty: true }) + const dirtyTab = { + input: Object.assign(new vscode.TabInputText(makeUri(mockTargetPath)), { + uri: makeUri(mockTargetPath), + }), + isDirty: true, + label: "mock-target-file.ts", + } + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + const originalClose = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "close") + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [dirtyTab] }], + configurable: true, + }) + Object.defineProperty(vscode.workspace, "textDocuments", { + get: () => [editor.document], + configurable: true, + }) + Object.defineProperty(vscode.window.tabGroups, "close", { + value: vi.fn().mockResolvedValue(false), + configurable: true, + }) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [], + originalContent: "", + }) + + try { + await expect(diffViewProvider.revertChanges()).rejects.toThrow("could not close the restored buffer") + // This test owns only the refusal handling; the restore-before-close ordering is + // asserted by the test above. What matters here is that the close was attempted + // without a force flag and the file survived a close the editor vetoed. + expect(vscode.window.tabGroups.close).toHaveBeenCalledWith(dirtyTab) + expect(fs.unlink).not.toHaveBeenCalled() + } finally { + if (originalTabs) { + Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + if (originalClose) { + Object.defineProperty(vscode.window.tabGroups, "close", originalClose) + } + } + }) + it("revertChanges() reports a save the editor did not perform for an existing file", async () => { + // A restore that applied but did not persist leaves a dirty buffer one save away from disk. + // save() resolves false in that case, and treating it as success would report a rollback that + // never completed. + const editor = buildActiveDiffEditor({ isDirty: true }) + editor.document.getText = vi.fn().mockReturnValue("streamed content") + editor.document.save = vi.fn().mockResolvedValue(false) + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + Object.defineProperty(vscode.workspace, "textDocuments", { get: () => [], configurable: true }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "modify", + createdDirs: [], + originalContent: "pre-stream content", + }) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + + try { + await expect(diffViewProvider.revertChanges()).rejects.toThrow("could not save the restored buffer") + expect(diffViewProvider["keepOrCloseEditedFile"]).not.toHaveBeenCalled() + } finally { + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + } + }) + it("revertChanges() does not save an existing file when the editor refuses the restore", async () => { + // The user must not be told the write was rolled back while the existing file still holds + // content they never approved: the refusal has to reach the reporting layer, and the save + // that would persist it must not run. + const editor = buildActiveDiffEditor({ isDirty: true }) + editor.document.getText = vi.fn().mockReturnValue("streamed content") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + Object.defineProperty(vscode.workspace, "textDocuments", { get: () => [], configurable: true }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(false) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "modify", + createdDirs: [], + originalContent: "pre-stream content", + }) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + + try { + await expect(diffViewProvider.revertChanges()).rejects.toThrow("could not restore the streamed buffer") + expect(editor.document.save).not.toHaveBeenCalled() + expect(diffViewProvider["keepOrCloseEditedFile"]).not.toHaveBeenCalled() + } finally { + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + } + }) + it("revertChanges() restores and saves an existing file through the real rollback path", async () => { + // The existing-file branch used to build its own WorkspaceEdit, discard applyEdit()'s boolean, + // and save unconditionally, so a refused restore wrote the unapproved streamed content to an + // existing file. It now runs through the same contract as the new-file branch. textDocuments is + // deliberately left empty: the branch reached this document through activeDiffEditor, and if the + // restore resolved it by path again it would find nothing and skip the restore in silence. + const editor = buildActiveDiffEditor({ isDirty: true }) + editor.document.getText = vi.fn().mockReturnValue("streamed content") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + Object.defineProperty(vscode.workspace, "textDocuments", { get: () => [], configurable: true }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "modify", + createdDirs: [], + originalContent: "pre-stream content", + }) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + + try { + await diffViewProvider.revertChanges() + } finally { + if (originalDocs) { + Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + } + } + + expect(vscode.workspace.applyEdit).toHaveBeenCalledTimes(1) + expect(mockWorkspaceEdit.replace).toHaveBeenCalledWith( + editor.document.uri, + expect.anything(), + "pre-stream content", + ) + expect(editor.document.save).toHaveBeenCalledTimes(1) + expect(fs.unlink).not.toHaveBeenCalled() + }) + it("revertChanges() restores the buffer through the real helpers when only the diff editor holds it", async () => { + // Same shape as the no-tab test above, but without stubbing restorePreStreamBuffer / + // saveBufferClean: the document is reached through activeDiffEditor, the restore is + // applied to it, and the save is asserted on the document itself. + const editor = buildActiveDiffEditor({ isDirty: true }) + editor.document.getText = vi.fn().mockReturnValue("streamed content") + const originalDocs = Object.getOwnPropertyDescriptor(vscode.workspace, "textDocuments") + // The document is registered so the real helpers can resolve it; the TAB list stays + // empty: only the diff editor holds the document. + Object.defineProperty(vscode.workspace, "textDocuments", { + get: () => [editor.document], + configurable: true, + }) + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + Object.defineProperty(vscode.window.tabGroups, "all", { get: () => [{ tabs: [] }], configurable: true }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [], + originalContent: "", + }) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + + try { + await diffViewProvider.revertChanges() + } finally { + if (originalDocs) Object.defineProperty(vscode.workspace, "textDocuments", originalDocs) + if (originalTabs) Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + + // The real rollback ran even though no plain text tab exists for the path. + expect(vscode.workspace.applyEdit).toHaveBeenCalledTimes(1) + expect(mockWorkspaceEdit.replace).toHaveBeenCalledWith(editor.document.uri, expect.anything(), "") + expect(editor.document.save).toHaveBeenCalledTimes(1) + // Rollback completed: the empty placeholder it created is unlinked. + expect(fs.unlink).toHaveBeenCalledWith(mockTargetPath) + }) + it("revertChanges() restores and saves the buffer once even when no plain text tab is open", async () => { + // The document can be open only through the diff editor: the user closed the plain text + // tab, so tabGroups.all yields no TabInputText for the path. Restoring inside the tab loop + // meant nothing ran, closeAllDiffViews() then skipped the dirty diff tab, and the unlink + // left a dirty diff tab holding unapproved content that a later save would recreate. + const editor = buildActiveDiffEditor({ isDirty: true }) + const teardown = diffViewProvider as unknown as { + restorePreStreamBuffer: (absolutePath: string) => Promise + saveBufferClean: (absolutePath: string) => Promise + } + const restore = vi.spyOn(teardown, "restorePreStreamBuffer").mockResolvedValue(undefined) + const saveClean = vi.spyOn(teardown, "saveBufferClean").mockResolvedValue(undefined) + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + Object.defineProperty(vscode.window.tabGroups, "all", { + // No plain TabInputText tab for this path - only the diff editor holds the document. + get: () => [{ tabs: [] }], + configurable: true, + }) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [], + originalContent: "", + }) + + let restoreArgs: unknown[] = [] + let saveArgs: unknown[] = [] + let restoreOrder = 0 + let saveOrder = 0 + try { + await diffViewProvider.revertChanges() + // mockRestore() clears the recorded history, so the evidence is captured here. + restoreArgs = restore.mock.calls.map((args) => args[0]) + saveArgs = saveClean.mock.calls.map((args) => args[0]) + restoreOrder = restore.mock.invocationCallOrder[0] + saveOrder = saveClean.mock.invocationCallOrder[0] + } finally { + // Do not leak the fixture: later tests read the module-level tabGroups.all. + if (originalTabs) { + Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + restore.mockRestore() + saveClean.mockRestore() + } + + // Exactly once each, restore before save, even with an empty tab list. + expect(restoreArgs).toEqual([mockTargetPath]) + expect(saveArgs).toEqual([mockTargetPath]) + expect(saveOrder).toBeGreaterThan(restoreOrder) + // The rollback still completed: the file it created was unlinked. + expect(fs.unlink).toHaveBeenCalledWith(mockTargetPath) + }) + it("revertChanges() restores the streamed buffer before closing it for a new file", async () => { + // tabGroups.close()'s second argument is preserveFocus, not a force-discard flag: closing + // a dirty tab prompts or is refused, which is how unapproved streamed content survived a + // "forced" close. The buffer must be restored to its pre-stream content and saved clean + // BEFORE the close, so close() never sees a dirty tab. + const editor = buildActiveDiffEditor({ isDirty: true }) + const teardown = diffViewProvider as unknown as { + restorePreStreamBuffer: (absolutePath: string) => Promise + saveBufferClean: (absolutePath: string) => Promise + } + let restoreArgs: unknown[] = [] + let saveArgs: unknown[] = [] + let restoreOrder = 0 + let saveOrder = 0 + const restore = vi.spyOn(teardown, "restorePreStreamBuffer").mockResolvedValue(undefined) + const saveClean = vi.spyOn(teardown, "saveBufferClean").mockResolvedValue(undefined) + const dirtyTab = { + input: Object.assign(new vscode.TabInputText(makeUri(mockTargetPath)), { + uri: makeUri(mockTargetPath), + }), + isDirty: true, + label: "mock-target-file.ts", + } + const originalTabs = Object.getOwnPropertyDescriptor(vscode.window.tabGroups, "all") + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [dirtyTab] }], + configurable: true, + }) + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: editor, + editType: "create", + createdDirs: [], + originalContent: "", + }) + + try { + await diffViewProvider.revertChanges() + // mockRestore() clears the recorded history, so the evidence is captured here. + restoreArgs = restore.mock.calls.map((args) => args[0]) + saveArgs = saveClean.mock.calls.map((args) => args[0]) + restoreOrder = restore.mock.invocationCallOrder[0] + saveOrder = saveClean.mock.invocationCallOrder[0] + } finally { + // Do not leak the fixture: later tests read the module-level tabGroups.all. + if (originalTabs) { + Object.defineProperty(vscode.window.tabGroups, "all", originalTabs) + } + restore.mockRestore() + saveClean.mockRestore() + } + + expect(restoreArgs).toEqual([mockTargetPath]) + expect(saveArgs).toEqual([mockTargetPath]) + expect(saveOrder).toBeGreaterThan(restoreOrder) + const closeOrder = vi.mocked(vscode.window.tabGroups.close).mock.invocationCallOrder.at(-1) + expect(closeOrder).toBeGreaterThan(saveOrder) + // No force flag: the tab is clean by the time it is closed. + expect(vscode.window.tabGroups.close).toHaveBeenCalledWith(dirtyTab) + expect(fs.unlink).toHaveBeenCalledWith(mockTargetPath) + }) + it("revertChanges() surfaces a placeholder delete that failed for a non-ENOENT reason", async () => { + // The ENOENT tolerance is scoped: an unlink that fails for another reason means the + // unapproved placeholder is still on disk, and the caller must not be told the + // rollback succeeded. + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs: [], + }) + vi.mocked(fs.unlink).mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }), + ) + + await expect(diffViewProvider.revertChanges()).rejects.toThrow("EACCES: permission denied") + }) + it("revertChanges() surfaces a non-ENOENT directory failure instead of swallowing it", async () => { + // The tolerance is scoped to ENOENT: a directory that exists but could not be removed + // is a real rollback failure and must reach the caller rather than be hidden. + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs: [`${mockCwd}/new-parent`], + }) + vi.mocked(fs.rmdir).mockRejectedValueOnce(Object.assign(new Error("EBUSY"), { code: "EBUSY" })) + + await expect(diffViewProvider.revertChanges()).rejects.toThrow("EBUSY") + }) + it("revertChanges() reports whether a rollback was performed", async () => { + // The return value is the caller's evidence. WriteToFileTool keeps its refused-rollback + // marker unless revertChanges() confirms a rollback, because a resolved promise also covers + // the case where this provider no longer has an edit in progress to restore - and that is + // exactly the state a fail-closed caller must not read as "the buffer was restored". + Object.assign(diffViewProvider, { + isEditing: false, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs: [], + }) + await expect(diffViewProvider.revertChanges()).resolves.toBe(false) + + Object.assign(diffViewProvider, { isEditing: true }) + await expect(diffViewProvider.revertChanges()).resolves.toBe(true) + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/mock-target-file.ts`) + }) + it("revertChanges() keeps rolling back the remaining directories when one rmdir hits ENOENT", async () => { + // Same tolerance as the placeholder, for the created dirs: a directory that never + // landed must not abort the rest of the rollback and must not surface as a new failure. + const createdDirs = [`${mockCwd}/new-parent`, `${mockCwd}/new-parent/nested`] + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs, + }) + vi.mocked(fs.rmdir).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + + await expect(diffViewProvider.revertChanges()).resolves.toBe(true) + + expect(fs.rmdir).toHaveBeenCalledTimes(2) + expect(fs.rmdir).toHaveBeenNthCalledWith(1, createdDirs[1]) + expect(fs.rmdir).toHaveBeenNthCalledWith(2, createdDirs[0]) + }) + it("revertChanges() stops before the document work for an existing file when no editor exists", async () => { + // The modify branch used to dereference activeDiffEditor unconditionally. When open() + // never produced an editor there is no document to restore, and the old code threw a + // TypeError out of the denial path instead of finishing the teardown. + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "modify", + createdDirs: [], + }) + + // No editor means no document to restore: the stream's content is still on disk, and the + // caller has to be able to tell that apart from a completed rollback. + await expect(diffViewProvider.revertChanges()).resolves.toBe(false) + + expect(vscode.workspace.applyEdit).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + }) + it("revertChanges() tolerates a placeholder that was never written", async () => { + // The failed open may have died before fs.writeFile ran; ENOENT during the + // rollback is success, not a new failure that would abort the cleanup. + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs: [], + }) + vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + + // The rollback ran, so it reports a performed rollback even though the delete hit ENOENT. + await expect(diffViewProvider.revertChanges()).resolves.toBe(true) + + // The rollback still attempted the delete - the tolerance is about not + // aborting the rest of the cleanup, not about skipping it. + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/mock-target-file.ts`) + }) + it("reset() clears relPath so a later teardown cannot reuse a stale target", async () => { + Object.assign(diffViewProvider, { isEditing: true, relPath: "mock-target-file.ts" }) + + await diffViewProvider.reset() + + expect(diffViewProvider["relPath"]).toBeUndefined() + }) + it("revertChanges() leaves an earlier edit's file alone when no edit is in progress", async () => { + // reset() used to leave relPath behind while clearing editType. A later stream that never + // opened a diff view then reached this method with a stale relPath and no editType, took + // the new-file branch, and unlinked the PREVIOUS edit's target - an existing user file, + // permanently. Nothing may be deleted unless an edit is actually in progress. + Object.assign(diffViewProvider, { + isEditing: false, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: undefined, + createdDirs: [`${mockCwd}/new-parent`], + }) + + await expect(diffViewProvider.revertChanges()).resolves.toBe(false) + + expect(fs.unlink).not.toHaveBeenCalled() + expect(fs.rmdir).not.toHaveBeenCalled() + }) + it("revertChanges() removes the placeholder and created dirs when open() failed before the editor existed", async () => { + // open() creates the parent dirs and an empty placeholder BEFORE it awaits + // openDiffEditor(). If that await rejects there is no activeDiffEditor, and the + // rollback used to bail out - leaving an empty file that the next execute() mistook + // for an existing file, which a denial then preserved. + const createdDirs = [`${mockCwd}/new-parent`, `${mockCwd}/new-parent/nested`] + Object.assign(diffViewProvider, { + isEditing: true, + relPath: "mock-target-file.ts", + activeDiffEditor: undefined, + editType: "create", + createdDirs, + }) + + await diffViewProvider.revertChanges() + + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/mock-target-file.ts`) + expect(fs.rmdir).toHaveBeenNthCalledWith(1, createdDirs[1]) + expect(fs.rmdir).toHaveBeenNthCalledWith(2, createdDirs[0]) + }) it("revertChanges() closes the file tab when the file was not open and untouched", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) @@ -1136,6 +1808,9 @@ describe("DiffViewProvider", () => { diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeFileTab"] = closeFileTab diffViewProvider["relPath"] = "mock-target-file.ts" + // revertChanges() returns early unless an edit is in progress: the unit moved the guard from + // !activeDiffEditor to !isEditing, so a fixture that only sets activeDiffEditor would exit here. + diffViewProvider["isEditing"] = true diffViewProvider["documentWasOpen"] = false diffViewProvider["userTouchedDocument"] = false diffViewProvider["preEditScrollLine"] = undefined @@ -1156,6 +1831,9 @@ describe("DiffViewProvider", () => { diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeFileTab"] = closeFileTab diffViewProvider["relPath"] = "mock-target-file.ts" + // revertChanges() returns early unless an edit is in progress: the unit moved the guard from + // !activeDiffEditor to !isEditing, so a fixture that only sets activeDiffEditor would exit here. + diffViewProvider["isEditing"] = true diffViewProvider["documentWasOpen"] = false diffViewProvider["userTouchedDocument"] = true diffViewProvider["preEditScrollLine"] = undefined @@ -1409,6 +2087,9 @@ describe("DiffViewProvider", () => { diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeFileTab"] = closeFileTab diffViewProvider["relPath"] = "mock-target-file.ts" + // revertChanges() returns early unless an edit is in progress: the unit moved the guard from + // !activeDiffEditor to !isEditing, so a fixture that only sets activeDiffEditor would exit here. + diffViewProvider["isEditing"] = true diffViewProvider["documentWasOpen"] = false diffViewProvider["userTouchedDocument"] = false // The user clicked inside the diff pane -- but this is a deny, so it must be ignored. From 9bbb2aea39a4e6abe68c388296bd7e748e1699d3 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:02:56 +0800 Subject: [PATCH 02/19] fix(tools): scope a refused write_to_file rollback to its target and carry the record its recovery needs This answers the Major on head 5693b89fea (WriteToFileTool.ts:702), which asked that the failed edit recovery state survive resets and that the marker be cleared only after that edit restoration succeeds. It also answers the two rows beside it on the same line, both of which the code already satisfied: the refusal reports once (reportedRollbackFailures plus the guard below) and returns a result through handleError, which already emits the tool_result (BaseTool.ts:193-194). The marker kept only an Error. The retry ran revertChanges() after resetDiffViewAfterWrite() had already run reset(), and reset() clears relPath, editType, isEditing, originalContent and createdDirs - which is exactly what revertChanges() needs, since it returns early when relPath or isEditing is missing. So the retry could not restore anything, the marker could never be satisfied, and the refusal outlived its own meaning. It was also task-wide: the map key was the task instance, so a write to a path that never opened the dirty buffer refused too, and the partial delta for that path dropped its own preview on the same key. The recovery record is now carried with the marker. DiffViewProvider publishes captureRollbackRecoveryContext() (:577) and attemptRollbackRecovery(context) (:601); the tool snapshots the context while the edit is still live (the teardown at :278 and the streaming failure at :744, both before the reset), keeps it in the marker (TaskRollbackFailure at :36) and hands it back when it tries the recovery again. Both methods are public because their caller is another module - WriteToFileTool, not a test - and the provider keeps ownership of its own state instead of exposing private fields for the tool to read. attemptRollbackRecovery() will not hijack a live edit for a different target: it reports false and the caller keeps refusing. Recovery-complete means a rollback actually ran, not that the attempt settled. The marker clears only at WriteToFileTool.ts:374, inside the if (restored) at :373, where restored is the boolean revertChanges() and attemptRollbackRecovery() publish: false when this provider had no edit in progress, true only at the end of a rollback that ran. A rejection and a no-op both keep the fail-closed refusal. Both gates are scoped by refusalBlocksPath() (:160, used at :349 and :699), which compares the target this write resolves to against the recorded absolutePath. A refusal captured while the provider held no live edit has no target to name, and for that case the refusal keeps its original task-wide reach rather than guessing a path. Two tests, and which is which matters: "lets a write to an unrelated path proceed while a recovery is outstanding on the refused path" is the control - it is red on 5693b89fea (the refusal was keyed by task instance, so the second path never reached the diff view or the approval prompt) and green here. "refuses the failed path until its recovery reports a rollback that ran, then releases it" is the pin - green before this commit and green after, which is what stops the fix from being simply a dropped refusal: the failed path is still refused, a recovery reporting nothing restored keeps the marker, and only a recovery reporting the rollback ran lifts it for the following write. Harness notes, because two of them changed what the assertions could mean: the utils/path module mock now answers arePathsEqual, which the tool imports for the gate; the provider double builds the record absolutePath through path.default.resolve because this file mocks path by spreading importActual and replacing only the top-level resolve, so the tool default-imports the real module and the record has to be built the way the provider builds it; and the two test sites that seed the marker directly (Task.spec.ts, writeToFileTool-partial-state-cleanup.spec.ts) now seed a record with context: undefined, which is the no-captured-target shape, not a behaviour change. Gates run on this tree before commit: vitest over writeToFileTool.spec, writeToFileTool-partial-state-cleanup.spec, DiffViewProvider.spec and Task.spec - 374 passed, 5 skipped (379); tsc --noEmit exit 0; the package lint script (eslint . --ext=ts --max-warnings=0 --cache ...) exit 0 with the eslint cache deleted first, because per-file eslint over an explicit file list is a quick check and is not the gate; and root check-types through git hook run pre-push - 11 tasks, 11 successful. Co-Authored-Changed-By: bseries-merge (review-row answers, controls and measurements for PR #1930). --- src/core/task/__tests__/Task.spec.ts | 7 +- src/core/tools/WriteToFileTool.ts | 89 +++++++++++-- ...teToFileTool-partial-state-cleanup.spec.ts | 10 +- .../tools/__tests__/writeToFileTool.spec.ts | 118 +++++++++++++++++- src/integrations/editor/DiffViewProvider.ts | 70 +++++++++++ 5 files changed, 276 insertions(+), 18 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3cea259129..cad233b2f5 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6683,7 +6683,12 @@ describe("Cline", () => { startTask: false, }) const markerKey = `${task.taskId}.${task.instanceId}` - writeToFileTool["taskRollbackFailures"].set(markerKey, new Error("write_to_file rollback failed")) + // No captured context: this is the shape of a refusal recorded while the provider held no live + // edit, which keeps the task-wide reach the test is about. + writeToFileTool["taskRollbackFailures"].set(markerKey, { + error: new Error("write_to_file rollback failed"), + context: undefined, + }) getTaskTestAccess(task).resetAssistantMessagePersistence() expect(writeToFileTool["taskRollbackFailures"].has(markerKey)).toBe(true) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 500bfb42af..427f0c8493 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -8,12 +8,13 @@ import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath, createDirectoriesForFile } from "../../utils/fs" import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text" -import { getReadablePath } from "../../utils/path" +import { arePathsEqual, getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import type { RollbackRecoveryContext } from "../../integrations/editor/DiffViewProvider" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -21,6 +22,21 @@ interface WriteToFileParams { path: string content: string } +/** + * A retained rollback refusal and the edit context its recovery needs. + * + * The error alone was enough to refuse a retry and not enough to finish one: the retry runs + * revertChanges(), which returns early once reset() has cleared the provider, so a marker that named no + * target could never be cleared by a successful recovery and the refusal outlived its own meaning. The + * context is what makes the refusal both narrower and recoverable - it names the path the dirty buffer + * belongs to, and it is what attemptRollbackRecovery() needs to roll that edit back after the reset. + * Absent only when the rollback was refused while the provider had no live edit to snapshot, in which + * case nothing can name the target and the refusal stays task-wide. + */ +interface TaskRollbackFailure { + error: Error + context: RollbackRecoveryContext | undefined +} /** * Per-task partial-streaming state tracked by WriteToFileTool. @@ -86,7 +102,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * instead of letting open() save that buffer before approval. Cleared once execute() reports * the failure, or when the task is torn down. */ - private taskRollbackFailures = new Map() + + private taskRollbackFailures = new Map() private reportedRollbackFailures = new WeakSet() private getPartialStreamFailureKey(task: Task): string { @@ -132,7 +149,28 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * ClineProvider.cleanupFailedHistoryTask()) never fires the TaskAborted cleanup and * this singleton would keep the disposed task and its diff-view provider. */ - /** Release the refused-rollback marker for a task that is being torn down. */ + /** Release the refused-rollback marker for a task that is being torn down. */ /** + * Does this refusal apply to the path this write is about to touch? + * + * The hazard is a dirty buffer in one document, so the refusal belongs to that document target and + * not to every write the task will attempt: an unrelated path cannot save a buffer it never opened. + * A refusal with no captured context cannot name its target, and for that case the refusal keeps + * its original task-wide reach rather than guessing at a path. + */ + private refusalBlocksPath( + task: Task, + refusal: TaskRollbackFailure | undefined, + relPath: string | undefined, + ): boolean { + if (!refusal) { + return false + } + if (!refusal.context || relPath === undefined) { + return true + } + return arePathsEqual(path.resolve(task.cwd, relPath), refusal.context.absolutePath) + } + public releaseTaskRollbackFailure(task: Task): void { this.taskRollbackFailures.delete(this.getPartialStreamFailureKey(task)) } @@ -232,13 +270,18 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // completed write, so reverting against that stale target would roll back - and for a // new file permanently delete - a file this stream never opened. let rollbackError: Error | undefined + let recoveryContext: RollbackRecoveryContext | undefined if (task.diffViewProvider.isEditing) { + // Snapshot before the attempt, not after it: a rollback that succeeds ends with reset(), which + // clears exactly the fields a later recovery would need, and a refused one leaves them set only + // until the reset this teardown runs right after. + recoveryContext = task.diffViewProvider.captureRollbackRecoveryContext() rollbackError = await this.revertDiffChangesBeforeReset(task) } if (rollbackError) { // The dirty buffer is still on disk after the refused rollback: keep the fail-closed // marker on the TASK, not the per-stream state this teardown is about to delete. - this.taskRollbackFailures.set(failureKey, rollbackError) + this.taskRollbackFailures.set(failureKey, { error: rollbackError, context: recoveryContext }) } await this.resetDiffViewAfterWrite(task) @@ -297,11 +340,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // once and touch no write path (no open/update/save, no directory creation). The state // is released here, so the parse-failure path - which only runs when execute() never // did - can never report the same failure a second time. - const rollbackFailure = this.taskRollbackFailures.get(this.getPartialStreamFailureKey(task)) - const streamState = this.taskPartialStreamState.get(this.getPartialStreamFailureKey(task)) - if (rollbackFailure || streamState?.rollbackFailure) { - const failureKey = this.getPartialStreamFailureKey(task) - const reported = rollbackFailure ?? streamState?.rollbackFailure + const failureKey = this.getPartialStreamFailureKey(task) + const refusal = this.taskRollbackFailures.get(failureKey) + // Scoped to the target the dirty buffer belongs to: a write to another path cannot save a + // buffer it never opened, and blocking it was both wider than the hazard and impossible to + // lift. The per-stream state is not consulted here - this teardown releases it, so a refusal + // that outlives the release lives only in the marker. + if (refusal && this.refusalBlocksPath(task, refusal, relPath)) { + const reported = refusal.error this.resetTaskPartialState(task) // Keep the marker until the dirty buffer is actually restored: a later write to the // same path would otherwise call open() and save the unapproved content. revertChanges() @@ -311,10 +357,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // rejection and a no-op both keep the fail-closed refusal. let restored = false try { - restored = await task.diffViewProvider.revertChanges() + // With a captured context the provider can roll back an edit its own reset() already forgot. + // Without one there is nothing to hand back, so the attempt is the provider own revert, which + // reports false when it has no edit in progress: the same contract, an attempt that cannot + // manufacture an edit nobody recorded. + restored = refusal.context + ? await task.diffViewProvider.attemptRollbackRecovery(refusal.context) + : await task.diffViewProvider.revertChanges() } catch (rollbackError) { console.error("Error restoring the refused write_to_file rollback:", rollbackError) } + // Cleared only on a recovery that reports it actually rolled the edit back. A recovery that + // rejects, or that reports it had nothing to restore, keeps the refusal: the dirty buffer is + // still there and the next open() would save content the user never approved. if (restored) { this.taskRollbackFailures.delete(failureKey) } @@ -638,7 +693,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // that unapproved buffer before approval, so this delta stops and execute() owns the refusal // report. The check sits outside the content guard on purpose: a delta that streamed an // empty document still has its ask on screen, and that one has to be finalized too. - if (this.taskRollbackFailures.has(this.getPartialStreamFailureKey(task))) { + // Path-scoped like the execute() gate: a delta for another path is not the delta that left a + // dirty buffer behind, and suppressing its preview made an unrelated write look broken. + const streamRefusal = this.taskRollbackFailures.get(this.getPartialStreamFailureKey(task)) + if (this.refusalBlocksPath(task, streamRefusal, relPath)) { // The ask above is already on screen and this return is the last thing the delta does: // finalize it, or the spinner and Save/Reject buttons stay live for a preview that will // never be opened. execute() owns the refusal report. @@ -680,6 +738,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } // The write was never approved: restore the document so a user save cannot // persist the failed streamed content (reset() alone leaves it dirty). + // Snapshot before the attempt, as in the teardown above: a refused rollback is only + // retryable if something kept the edit context the rollback needs, and the reset below + // is about to clear it. + const recoveryContext = task.diffViewProvider.captureRollbackRecoveryContext() const rollbackError = await this.revertDiffChangesBeforeReset(task) if (rollbackError) { // The rollback did not finish: the placeholder and any created directories are still @@ -697,7 +759,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // unapproved streamed content before approval. Record the failure on this task's // state; execute() reports it once and touches no write path. partialStreamState.rollbackFailure = rollbackFailure - this.taskRollbackFailures.set(this.getPartialStreamFailureKey(task), rollbackFailure) + this.taskRollbackFailures.set(this.getPartialStreamFailureKey(task), { + error: rollbackFailure, + context: recoveryContext, + }) } await this.resetDiffViewAfterWrite(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 c108050f2c..3557e4c519 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -184,10 +184,12 @@ describe("WriteToFileTool partial-delta cancellation", () => { const task = buildStreamTask("refused-empty-content", "inst-c6") seedStablePath(task as unknown as Task) const failureKey = writeToFileTool["getPartialStreamFailureKey"](task as unknown as Task) - writeToFileTool["taskRollbackFailures"].set( - failureKey, - new Error("EPERM: cannot restore the streamed document"), - ) + // No captured context on this refusal: the provider held no live edit to snapshot, so the refusal + // keeps its task-wide reach and this delta is suppressed on that basis alone. + writeToFileTool["taskRollbackFailures"].set(failureKey, { + error: new Error("EPERM: cannot restore the streamed document"), + context: undefined, + }) const emptyContentBlock = { ...partialBlock, params: { path: "src/demo.ts", content: "" }, diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index ce9a7bb5de..1b68c8def9 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -46,6 +46,16 @@ vi.mock("../../../utils/pathUtils", () => ({ vi.mock("../../../utils/path", () => ({ getReadablePath: vi.fn().mockReturnValue("test/path.txt"), + // WriteToFileTool imports arePathsEqual from this module for its rollback-refusal gate, so the + // module mock has to answer it. The gate hands both arguments already resolved against the task + // cwd, so the double only needs what the real helper observably does to absolute paths: compare + // exactly off Windows and case-insensitively on it, where the filesystem is case-insensitive. + arePathsEqual: vi.fn((first: string | undefined, second: string | undefined) => { + if (first === undefined || second === undefined) { + return false + } + return process.platform === "win32" ? first.toLowerCase() === second.toLowerCase() : first === second + }), })) vi.mock("../../../utils/text-normalization", () => ({ @@ -119,6 +129,12 @@ describe("writeToFileTool", () => { const mockedEveryLineHasLineNumbers = everyLineHasLineNumbers as MockedFunction const mockedStripLineNumbers = stripLineNumbers as MockedFunction const mockedPathResolve = path.resolve as MockedFunction + // The path mock spreads importActual and replaces only the top-level resolve, so the module default - + // what WriteToFileTool gets from its default import - still carries real resolution. The recovery + // record has to be built with that same resolver, because the gate compares against it; the namespace + // type has no default property, so the reach is asserted with the reason written down. + const realPathResolve = (...args: string[]): string => + (path as unknown as { default: { resolve: (...a: string[]) => string } }).default.resolve(...args) const mockCline: any = {} let mockAskApproval: ReturnType> @@ -169,7 +185,30 @@ describe("writeToFileTool", () => { open: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), reset: vi.fn().mockResolvedValue(undefined), - // A diff view that has an edit in progress: revertChanges() reports the rollback it + // A refused rollback hands its recovery context to WriteToFileTool, which keeps it across the + // provider reset and hands it back when it tries the recovery again. The double reports the + // target its last open() was given - what the provider holds in relPath at that moment - and + // joins it to the fixture cwd by hand, because path.resolve is mocked to one constant here. + captureRollbackRecoveryContext: vi.fn(() => { + const provider = mockCline.diffViewProvider + if (!provider.isEditing) { + return undefined + } + const opened: string = provider.open.mock.calls.slice(-1)[0]?.[0] ?? testFilePath + // path.default is the real module here: the mock factory spreads importActual and replaces + // only the top-level resolve, and WriteToFileTool default-imports path, so the gate compares + // against real resolution. The record has to be built the same way the provider builds it. + return { + relPath: opened, + absolutePath: realPathResolve("/", opened), + editType: provider.editType ?? "create", + createdDirs: [], + originalContent: provider.originalContent, + } + }), + // The recovery entry point runs the rollback it was given the context for, so the double + // forwards to the same revertChanges the tests already script. + attemptRollbackRecovery: vi.fn((context: unknown) => mockCline.diffViewProvider.revertChanges(context)), // A diff view that has an edit in progress: revertChanges() reports the rollback it // performed. Tests that need the no-op case - an edit already cleared by reset() - return // false instead. revertChanges: vi.fn().mockResolvedValue(true), @@ -1276,6 +1315,83 @@ describe("writeToFileTool", () => { expect(mockAskApproval).toHaveBeenCalledTimes(1) expect(mockPushToolResult).toHaveBeenCalled() }) + it("lets a write to an unrelated path proceed while a recovery is outstanding on the refused path", async () => { + // The case the review row is about. The refusal was keyed by task instance alone, so every + // later write_to_file in the task refused - including one aimed at a path that never opened the + // dirty buffer. One document is the hazard, so one document has to be the refusal. + mockCline.diffViewProvider.update.mockRejectedValueOnce( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValueOnce( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + await executeWriteFileTool({}, { isPartial: true }) + // The second delta is mid-edit, which is what open() leaves behind on a real stream; the refusal + // has to be recorded with a live edit to snapshot or it cannot name its own target. The double + // does not model that side effect of open(), and the delta itself never touches isEditing. + mockCline.diffViewProvider.isEditing = true + await executeWriteFileTool({}, { isPartial: true }) + // The teardown runs resetDiffViewAfterWrite(), so by the time a later call arrives the provider + // holds no edit again - the state a refused restore actually leaves behind. + mockCline.diffViewProvider.isEditing = false + + const otherFilePath = "test/other-file.txt" + await executeWriteFileTool({ path: otherFilePath }) + + // The unrelated write reached the diff view and the approval prompt: it was not refused. This is + // the assertion that goes red while the refusal is keyed by task instance alone. + expect(mockCline.diffViewProvider.open).toHaveBeenLastCalledWith(otherFilePath) + expect(mockCline.diffViewProvider.update).toHaveBeenLastCalledWith(testContent, true) + expect(mockAskApproval).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + const refusalReports = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(refusalReports).toHaveLength(0) + // The refusal itself stays outstanding: an unrelated write succeeding says nothing about the + // buffer the refused stream left on its own path, and the record is what the next write to THAT + // path is refused against. + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + const failureKey = `${mockCline.taskId}.${mockCline.instanceId}` + const refusal = writeToFileTool["taskRollbackFailures"].get(failureKey) as + { error: Error; context: { absolutePath: string } | undefined } | undefined + expect(refusal?.context?.absolutePath).toBe(absoluteFilePath) + }) + + it("refuses the failed path until its recovery reports a rollback that ran, then releases it", async () => { + // The pin that keeps the narrower refusal honest: the failed path is still refused, the marker + // survives a recovery that reports it rolled nothing back, and only a recovery that reports it + // ran lets the write through. + mockCline.diffViewProvider.update.mockRejectedValueOnce( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValueOnce( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + await executeWriteFileTool({}, { isPartial: true }) + mockCline.diffViewProvider.isEditing = true + await executeWriteFileTool({}, { isPartial: true }) + mockCline.diffViewProvider.isEditing = false + + // From here every recovery attempt reports that it had nothing to restore. + mockCline.diffViewProvider.revertChanges.mockResolvedValue(false) + + await executeWriteFileTool({}) + expect(mockAskApproval).not.toHaveBeenCalled() + expect(mockPushToolResult).not.toHaveBeenCalled() + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + + // A recovery that reports the rollback ran clears the marker. The refusal still owns this + // call - it reports the failure and returns - so the write that proves the lift is the next one. + mockCline.diffViewProvider.revertChanges.mockResolvedValueOnce(true) + await executeWriteFileTool({}) + expect(writeToFileTool["taskRollbackFailures"].size).toBe(0) + expect(mockAskApproval).not.toHaveBeenCalled() + + await executeWriteFileTool({}) + expect(writeToFileTool["taskRollbackFailures"].size).toBe(0) + expect(mockCline.diffViewProvider.open).toHaveBeenLastCalledWith(testFilePath) + expect(mockAskApproval).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + }) it("finalizes the partial tool ask when a refused rollback suppresses the preview", async () => { // A parse-failure teardown with a failed rollback sets the refused-rollback marker. The next diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index b7b41e3f75..be89014325 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -28,6 +28,27 @@ function isEnoent(error: unknown): boolean { export const DIFF_VIEW_URI_SCHEME = "cline-diff" export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Zoo's Changes" +/** + * The edit context a refused rollback needs in order to be re-attempted after reset(). + * + * reset() clears relPath, editType, isEditing, originalContent and createdDirs, and revertChanges() + * returns early without touching anything when relPath or isEditing is missing. A caller that wants to + * try the recovery again after that reset therefore has to bring the edit context back with it; this is + * the record it carries. It is exported because WriteToFileTool, a different module, stores it between + * the refusal and the retry. + */ +export interface RollbackRecoveryContext { + /** The target as this provider held it, so a later recovery can hand it back unchanged. */ + relPath: string + /** The same target resolved against this provider cwd, for callers that compare targets. */ + absolutePath: string + /** create and modify roll back differently: one unlinks the file it made, the other restores content. */ + editType?: "create" | "modify" + /** Directories this edit made, so a recovery that unlinks the file can remove them too. */ + createdDirs: string[] + /** The file content before the edit, for the modify branch. */ + originalContent?: string +} // TODO: https://github.com/cline/cline/pull/3354 export class DiffViewProvider { @@ -544,6 +565,55 @@ export class DiffViewProvider { } } + /** + * Snapshot of the live edit, taken while it can still be rolled back. + * + * Public because WriteToFileTool calls it: a rollback refusal is reported and then retained, and + * the retention has to outlive the provider own reset. The alternative - the tool reading relPath, + * editType, createdDirs and originalContent through the provider - puts this class private shape in + * another module hands, which is what the recovery record replaces. Returns undefined when there is + * no edit in progress, which tells the caller there is nothing here to recover. + */ + captureRollbackRecoveryContext(): RollbackRecoveryContext | undefined { + if (!this.relPath || !this.isEditing) { + return undefined + } + return { + relPath: this.relPath, + absolutePath: path.resolve(this.cwd, this.relPath), + editType: this.editType, + createdDirs: [...this.createdDirs], + originalContent: this.originalContent, + } + } + + /** + * Attempt a rollback from a context captured before a reset() cleared this provider state. + * + * Public because WriteToFileTool calls it, for the same reason as captureRollbackRecoveryContext(). + * The tool hands back the record it was given and this class still owns its own state: it installs + * the edit context, runs the rollback it would have run had the reset not happened, and reports + * whether a rollback actually ran, using the same evidence contract revertChanges() publishes. + * + * A live edit is not hijacked. If an edit is in progress for another target this recovery cannot + * run without rolling back somebody else work, so it reports false and the caller keeps refusing. + */ + async attemptRollbackRecovery(context: RollbackRecoveryContext): Promise { + if (this.isEditing) { + const livePath = this.relPath ? path.resolve(this.cwd, this.relPath) : undefined + if (!livePath || !arePathsEqual(livePath, context.absolutePath)) { + return false + } + return this.revertChanges() + } + this.relPath = context.relPath + this.editType = context.editType + this.createdDirs = [...context.createdDirs] + this.originalContent = context.originalContent + this.isEditing = true + return this.revertChanges() + } + /** * Rolls the streamed edit back and reports whether a rollback was actually performed. * From b7e3d8128b592e38b187b80a9ce1e6806e86c331 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:19:56 +0800 Subject: [PATCH 03/19] test(assistant-message): give the abandoned-stream double the provider entry points the teardown now calls The head this branch just pushed (9bbb2aea3) left core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts red - locally on Windows with the same signatures CI reported on ubuntu (5 failing cases: two tool_results where one is expected, revertChanges called zero times, and the open-count assertion that follows), and the windows matrix job cancelled by fail-fast. My gate set for that commit was writeToFileTool.spec, writeToFileTool-partial-state-cleanup.spec, DiffViewProvider.spec and Task.spec, and this file was not in it, which is how a head with a red required job got pushed. The contract did not move, so no assertion moved. At 9bbb2aea3 the abandoned-stream teardown still runs captureRollbackRecoveryContext() at WriteToFileTool.ts:279, then revertDiffChangesBeforeReset() at :280 - which is the call that invokes revertChanges() - then resetDiffViewAfterWrite() at :287, i.e. revert before reset in the order the spec pins at its lines 308-314. What the commit added is a provider call in front of that revert, and this spec builds its Task as a structural double whose diffViewProvider literal only carried editType, isEditing, open, update, reset and revertChanges. The missing member threw inside the teardown before the revert could run, which is why revertChanges recorded zero calls, why a second tool_result appeared, and why the later open-count assertion drifted. The fix is the double, not the assertions: it now carries captureRollbackRecoveryContext() and attemptRollbackRecovery() implemented the way the provider implements them (a record exists only while an edit is live, resolved against the task cwd the gate compares against, and a recovery that reports no live edit restores nothing), and its revertChanges double returns the boolean the real one returns. Also in this commit: the docblock that explains refusalBlocksPath() had been spliced in front of releaseTaskRollbackFailure() own one-line doc, leaving the method undocumented and two blocks fused; the release line is back on its method. The five spec files that fail in a full local src run (dist_assets, ShadowCheckpointService, MarketplaceManager, rules, code-index parser) fail identically - 93 failed, 54 passed - with WriteToFileTool.ts reverted to 5693b89fea, and src/dist does not exist in this worktree, so they are build-artifact gaps in this environment rather than fallout here. Gates on this tree: vitest over core/assistant-message, core/tools, core/task and integrations/editor - 2002 passed, 5 skipped (2007) across 98 files; tsc --noEmit exit 0; and the package lint script (eslint . --ext=ts --max-warnings=0 --cache ...) exit 0 with the eslint cache deleted first. core/assistant-message is in the standing gate set for both branches from here on. Co-Authored-Changed-By: bseries-merge (review-row answers, controls and measurements for PR #1930). --- ...ssage-write-to-file-stream-cleanup.spec.ts | 47 +++++++++++++++++-- src/core/tools/WriteToFileTool.ts | 3 +- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts index 50125a9055..07f27df789 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts @@ -13,8 +13,12 @@ import { DispatchState } from "../../task/dispatchState" import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" import { isValidToolName } from "../../tools/validateToolUse" import { writeToFileTool } from "../../tools/WriteToFileTool" +import type { RollbackRecoveryContext } from "../../../integrations/editor/DiffViewProvider" +import { arePathsEqual } from "../../../utils/path" import { fileExistsAtPath } from "../../../utils/fs" +import path from "path" + import { presentAssistantMessage } from "../presentAssistantMessage" vi.mock("../../task/Task") @@ -97,7 +101,11 @@ interface PresenterTask { open: MockedFunction<(relPath: string) => Promise> update: MockedFunction<(content: string, single: boolean) => Promise> reset: MockedFunction<() => Promise> - revertChanges: MockedFunction<() => Promise> + // The provider own rollback reports whether it performed one, and the tool reads that boolean + // before it drops a refused-rollback marker; the double carries it for the same reason. + revertChanges: MockedFunction<() => Promise> + captureRollbackRecoveryContext: MockedFunction<() => RollbackRecoveryContext | undefined> + attemptRollbackRecovery: MockedFunction<(context: RollbackRecoveryContext) => Promise> } } @@ -111,15 +119,46 @@ function buildTask(): PresenterTask { open: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockResolvedValue(undefined), reset: vi.fn().mockResolvedValue(undefined), - revertChanges: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(true), + captureRollbackRecoveryContext: vi.fn().mockReturnValue(undefined), + attemptRollbackRecovery: vi.fn().mockResolvedValue(false), } // Mirror the real provider: an open edit is what the rollback below is allowed to revert, // and both revert and reset end the edit. - diffViewProvider.open.mockImplementation(async () => { + const taskCwd = "/mock/cwd" + // The relPath a live edit holds, so a captured record names its target the way the real provider + // does - resolved against the task cwd, which is what the tool gate compares against. + let openedRelPath: string | undefined + diffViewProvider.open.mockImplementation(async (relPath: string) => { + openedRelPath = relPath diffViewProvider.isEditing = true }) diffViewProvider.revertChanges.mockImplementation(async () => { + const wasEditing = diffViewProvider.isEditing diffViewProvider.isEditing = false + openedRelPath = undefined + return wasEditing + }) + // Mirrors the provider: a record exists only while an edit is live, and a recovery is a rollback + // attempt whose result is the boolean the tool acts on. + diffViewProvider.captureRollbackRecoveryContext.mockImplementation(() => + diffViewProvider.isEditing && openedRelPath + ? { + relPath: openedRelPath, + absolutePath: path.resolve(taskCwd, openedRelPath), + editType: diffViewProvider.editType, + createdDirs: [], + } + : undefined, + ) + diffViewProvider.attemptRollbackRecovery.mockImplementation(async (context: RollbackRecoveryContext) => { + if (!diffViewProvider.isEditing || !openedRelPath) { + return false + } + if (!arePathsEqual(path.resolve(taskCwd, openedRelPath), context.absolutePath)) { + return false + } + return diffViewProvider.revertChanges() }) diffViewProvider.reset.mockImplementation(async () => { diffViewProvider.isEditing = false @@ -128,7 +167,7 @@ function buildTask(): PresenterTask { const task: PresenterTask = { taskId: "task-1", instanceId: "instance-1", - cwd: "/mock/cwd", + cwd: taskCwd, abort: false, abandoned: false, presentAssistantMessageLocked: false, diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 427f0c8493..cc0f5d01df 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -149,7 +149,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * ClineProvider.cleanupFailedHistoryTask()) never fires the TaskAborted cleanup and * this singleton would keep the disposed task and its diff-view provider. */ - /** Release the refused-rollback marker for a task that is being torn down. */ /** + /** * Does this refusal apply to the path this write is about to touch? * * The hazard is a dirty buffer in one document, so the refusal belongs to that document target and @@ -171,6 +171,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return arePathsEqual(path.resolve(task.cwd, relPath), refusal.context.absolutePath) } + /** Release the refused-rollback marker for a task that is being torn down. */ public releaseTaskRollbackFailure(task: Task): void { this.taskRollbackFailures.delete(this.getPartialStreamFailureKey(task)) } From 6147c93e530a81cb0b0c812992edbed569227cbb Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:38:28 +0800 Subject: [PATCH 04/19] test(tools): compare the recovery record against the resolver that produced it, not against a drive-hardcoded constant The head this branch pushed an hour ago (b7e3d8128) cleared the ubuntu required job - the abandoned-stream spec now passes on Linux - and went red on the windows sibling instead, on my own control case: FAIL core/tools/__tests__/writeToFileTool.spec.ts > writeToFileTool > error handling > lets a write to an unrelated path proceed while a recovery is outstanding on the refused path AssertionError: expected 'D:\test\file.txt' to be 'C:\test\file.txt' // Object.is equality at writeToFileTool.spec.ts:1356 - expect(refusal?.context?.absolutePath).toBe(absoluteFilePath) Two different platform axes were being compared as if they were one. absoluteFilePath is the spec constant, platform-conditional with a hard-coded drive: "C:\test\file.txt" on win32, "/test/file.txt" elsewhere. The value it was compared against is the recovery record my capture double builds with the real resolver - realPathResolve("/", opened) - and on Windows path.resolve takes a bare root from the drive the process cwd sits on: C: on this workstation, D: on the hosted windows runner, whose checkout lives under D:. Same relative path, same shape, different drive, so the case was green on every Linux matrix and on any Windows box whose cwd is on C: - which is every box I have - and red only where the runner put the repo on another drive. This is a defect in the assertion I wrote, not in the gate: the scoping logic, the record, and the recovery path are all drive-consistent within a process because both sides of the real comparison go through the same resolver. The assertion now compares the record against the same construction that produced it - realPathResolve("/", testFilePath) - and adds the negative half, that it is not the unrelated target under the same resolver, so the claim it makes (the record names the refused write target) is the claim that is checked, and it holds on both drives. The win32-conditional constant itself is left alone: 19 spec files use that idiom at 90 sites, and the fragility is not the idiom, it is comparing a constant written in it against a real-resolver value. Gates on this tree: writeToFileTool.spec 61 passed / 5 skipped; the standing widened scope (core/assistant-message, core/tools, core/task, integrations/editor) 98 files, 2002 passed / 5 skipped; tsc --noEmit exit 0; the package lint script exit 0 with the eslint cache deleted first. No production file changed in this commit, so the mutation picture of the branch is unchanged by it. No changeset, no CHANGELOG entry, no lint suppression added or increased. Co-Authored-Changed-By: bseries-merge (review-row answers, controls and measurements for PR #1930). --- src/core/tools/__tests__/writeToFileTool.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 1b68c8def9..0bf1b85803 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -1353,7 +1353,14 @@ describe("writeToFileTool", () => { const failureKey = `${mockCline.taskId}.${mockCline.instanceId}` const refusal = writeToFileTool["taskRollbackFailures"].get(failureKey) as { error: Error; context: { absolutePath: string } | undefined } | undefined - expect(refusal?.context?.absolutePath).toBe(absoluteFilePath) + // Compared against the same resolution the record was built with, not against the spec + // constant: the constant hard-codes a drive (`C:\test\file.txt` on win32) while the record + // comes from the real resolver, which on Windows resolves a bare root against the drive the + // process cwd is on - `D:\test\file.txt` on a runner whose checkout sits on D:. The claim is + // that the record names the refused write target, so it is asserted against the refused + // target under that resolver and against the unrelated target for the negative half. + expect(refusal?.context?.absolutePath).toBe(realPathResolve("/", testFilePath)) + expect(refusal?.context?.absolutePath).not.toBe(realPathResolve("/", otherFilePath)) }) it("refuses the failed path until its recovery reports a rollback that ran, then releases it", async () => { From ae4511fa67d7136e62ed99edd8433aea768be803 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:58:28 +0800 Subject: [PATCH 05/19] fix(u5): keep one refused-rollback record per affected path The refusal gate is path-scoped but the marker map held a single record per task, so a second refused rollback on another path replaced the first record and a later write to the first path was no longer refused against its own dirty buffer. Record list per task, scoped lookup, and lifting one record no longer lifts the others. Also binds the real arePathsEqual in the refusal-gate tests instead of a hand-written copy, and updates the marker-shape spec reads. Review rows answered: WriteToFileTool.ts:766 (Major) and writeToFileTool.spec.ts:58. mutation-diff, webview-visual and CodeRabbit are advisory on this repo; this push also re-triggers them because we cannot re-run a leg ourselves (403 Must have admin rights). CI compile and the platform unit legs are the proof for the lint-sensitive parts of this commit. --- src/core/task/__tests__/Task.spec.ts | 10 ++- src/core/tools/WriteToFileTool.ts | 83 ++++++++++++++---- ...teToFileTool-partial-state-cleanup.spec.ts | 10 ++- .../tools/__tests__/writeToFileTool.spec.ts | 87 ++++++++++++++++--- 4 files changed, 151 insertions(+), 39 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index cad233b2f5..4f7fdf9b42 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -6685,10 +6685,12 @@ describe("Cline", () => { const markerKey = `${task.taskId}.${task.instanceId}` // No captured context: this is the shape of a refusal recorded while the provider held no live // edit, which keeps the task-wide reach the test is about. - writeToFileTool["taskRollbackFailures"].set(markerKey, { - error: new Error("write_to_file rollback failed"), - context: undefined, - }) + writeToFileTool["taskRollbackFailures"].set(markerKey, [ + { + error: new Error("write_to_file rollback failed"), + context: undefined, + }, + ]) getTaskTestAccess(task).resetAssistantMessagePersistence() expect(writeToFileTool["taskRollbackFailures"].has(markerKey)).toBe(true) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index cc0f5d01df..6a5f06c9b9 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -96,20 +96,74 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { private taskPartialStreamState = new Map() /** - * Refused-rollback markers per task. These outlive resetTaskPartialState()/clearTaskState() - * because Task clears the per-stream state at the start of EVERY API request: if the dirty - * streamed buffer survived a refused rollback, the retry request must still fail closed - * instead of letting open() save that buffer before approval. Cleared once execute() reports - * the failure, or when the task is torn down. + * Refused-rollback markers per task, one record per affected path. These outlive + * resetTaskPartialState()/clearTaskState() because Task clears the per-stream state at the start + * of EVERY API request: if the dirty streamed buffer survived a refused rollback, the retry + * request must still fail closed instead of letting open() save that buffer before approval. + * Cleared once execute() reports the failure, or when the task is torn down. + * + * The value is a list rather than a single record because the gate is path-scoped. With one slot + * per task, a refused rollback on path B replaced the record for path A, so path A lost the only + * thing that was keeping its dirty, unapproved buffer from being saved by a later open() - the + * gate spoke about "this path", while the storage could remember only one path at a time. */ - private taskRollbackFailures = new Map() + private taskRollbackFailures = new Map() private reportedRollbackFailures = new WeakSet() private getPartialStreamFailureKey(task: Task): string { return `${task.taskId}.${task.instanceId}` } + /** + * Record a refused rollback against the path its dirty buffer belongs to. + * + * A record captured with a live edit names its target, so a later refusal for a different target + * is added beside it rather than replacing it; a refusal for the SAME target replaces the older + * record, because it describes the same buffer and the recovery context that matters is the newest + * one. A record whose capture found no live edit cannot name a target: it keeps the task-wide + * reach it shipped with, and only another refusal that likewise cannot name one replaces it. + */ + private recordRollbackFailure(task: Task, failure: TaskRollbackFailure): void { + const key = this.getPartialStreamFailureKey(task) + const existing = this.taskRollbackFailures.get(key) ?? [] + const target = failure.context?.absolutePath + const kept = target + ? existing.filter((record) => record.context?.absolutePath !== target) + : existing.filter((record) => !record.context) + this.taskRollbackFailures.set(key, [...kept, failure]) + } + + /** + * The recorded refusal that applies to this write target, if any. + * + * Scoped by the rule the callers used to apply by hand on the single record: a record with a + * captured context blocks only its own path, and a record without one blocks the whole task, + * because nothing about it says which buffer it belongs to. + */ + private refusalForPath(task: Task, relPath: string | undefined): TaskRollbackFailure | undefined { + const records = this.taskRollbackFailures.get(this.getPartialStreamFailureKey(task)) + return records?.find((record) => this.refusalBlocksPath(task, record, relPath)) + } + + /** + * Drop one recorded refusal once its own buffer is confirmed restored, leaving every other + * path's refusal in place: lifting A's block must not silently lift B's. + */ + private forgetRollbackFailure(task: Task, failure: TaskRollbackFailure): void { + const key = this.getPartialStreamFailureKey(task) + const records = this.taskRollbackFailures.get(key) + if (!records) { + return + } + const remaining = records.filter((record) => record !== failure) + if (remaining.length === 0) { + this.taskRollbackFailures.delete(key) + } else { + this.taskRollbackFailures.set(key, remaining) + } + } + /** * Get this task's partial stream state, creating it on first use and registering the * TaskAborted teardown listener exactly once per task. @@ -265,7 +319,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return false } - const failureKey = this.getPartialStreamFailureKey(task) this.resetTaskPartialState(task) // Only an edit in progress may be reverted. DiffViewProvider keeps relPath after a // completed write, so reverting against that stale target would roll back - and for a @@ -282,7 +335,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { if (rollbackError) { // The dirty buffer is still on disk after the refused rollback: keep the fail-closed // marker on the TASK, not the per-stream state this teardown is about to delete. - this.taskRollbackFailures.set(failureKey, { error: rollbackError, context: recoveryContext }) + this.recordRollbackFailure(task, { error: rollbackError, context: recoveryContext }) } await this.resetDiffViewAfterWrite(task) @@ -341,8 +394,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // once and touch no write path (no open/update/save, no directory creation). The state // is released here, so the parse-failure path - which only runs when execute() never // did - can never report the same failure a second time. - const failureKey = this.getPartialStreamFailureKey(task) - const refusal = this.taskRollbackFailures.get(failureKey) + const refusal = this.refusalForPath(task, relPath) // Scoped to the target the dirty buffer belongs to: a write to another path cannot save a // buffer it never opened, and blocking it was both wider than the hazard and impossible to // lift. The per-stream state is not consulted here - this teardown releases it, so a refusal @@ -372,7 +424,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // rejects, or that reports it had nothing to restore, keeps the refusal: the dirty buffer is // still there and the next open() would save content the user never approved. if (restored) { - this.taskRollbackFailures.delete(failureKey) + this.forgetRollbackFailure(task, refusal) } // The parse-failure teardown may already have reported this exact failure: keep the // fail-closed refusal but do not report the same Error twice. @@ -696,8 +748,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // empty document still has its ask on screen, and that one has to be finalized too. // Path-scoped like the execute() gate: a delta for another path is not the delta that left a // dirty buffer behind, and suppressing its preview made an unrelated write look broken. - const streamRefusal = this.taskRollbackFailures.get(this.getPartialStreamFailureKey(task)) - if (this.refusalBlocksPath(task, streamRefusal, relPath)) { + const streamRefusal = this.refusalForPath(task, relPath) + if (streamRefusal) { // The ask above is already on screen and this return is the last thing the delta does: // finalize it, or the spinner and Save/Reject buttons stay live for a preview that will // never be opened. execute() owns the refusal report. @@ -760,10 +812,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // unapproved streamed content before approval. Record the failure on this task's // state; execute() reports it once and touches no write path. partialStreamState.rollbackFailure = rollbackFailure - this.taskRollbackFailures.set(this.getPartialStreamFailureKey(task), { - error: rollbackFailure, - context: recoveryContext, - }) + this.recordRollbackFailure(task, { error: rollbackFailure, context: recoveryContext }) } await this.resetDiffViewAfterWrite(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 3557e4c519..92fbfd14a2 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -186,10 +186,12 @@ describe("WriteToFileTool partial-delta cancellation", () => { const failureKey = writeToFileTool["getPartialStreamFailureKey"](task as unknown as Task) // No captured context on this refusal: the provider held no live edit to snapshot, so the refusal // keeps its task-wide reach and this delta is suppressed on that basis alone. - writeToFileTool["taskRollbackFailures"].set(failureKey, { - error: new Error("EPERM: cannot restore the streamed document"), - context: undefined, - }) + writeToFileTool["taskRollbackFailures"].set(failureKey, [ + { + error: new Error("EPERM: cannot restore the streamed document"), + context: undefined, + }, + ]) const emptyContentBlock = { ...partialBlock, params: { path: "src/demo.ts", content: "" }, diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 0bf1b85803..d3697dd764 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -44,19 +44,19 @@ vi.mock("../../../utils/pathUtils", () => ({ isPathOutsideWorkspace: vi.fn().mockReturnValue(false), })) -vi.mock("../../../utils/path", () => ({ - getReadablePath: vi.fn().mockReturnValue("test/path.txt"), - // WriteToFileTool imports arePathsEqual from this module for its rollback-refusal gate, so the - // module mock has to answer it. The gate hands both arguments already resolved against the task - // cwd, so the double only needs what the real helper observably does to absolute paths: compare - // exactly off Windows and case-insensitively on it, where the filesystem is case-insensitive. - arePathsEqual: vi.fn((first: string | undefined, second: string | undefined) => { - if (first === undefined || second === undefined) { - return false - } - return process.platform === "win32" ? first.toLowerCase() === second.toLowerCase() : first === second - }), -})) +vi.mock("../../../utils/path", async (importOriginal) => { + const realPath = await importOriginal() + return { + ...realPath, + getReadablePath: vi.fn().mockReturnValue("test/path.txt"), + // The rollback-refusal gate compares paths the real resolver produced, so it gets the real + // helper. This mock used to carry a hand-written copy of arePathsEqual: a change to the helper - + // the thing the gate actually depends on - could not fail any test in this file, and a copy drifts + // by construction. Only getReadablePath stays doubled, because the string it returns is fixture + // input for this file rather than production behaviour. + arePathsEqual: realPath.arePathsEqual, + } +}) vi.mock("../../../utils/text-normalization", () => ({ unescapeHtmlEntities: vi.fn().mockImplementation((content) => { @@ -1289,6 +1289,65 @@ describe("writeToFileTool", () => { expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) }) + it("keeps the refusal on the first path when a second path has its own rollback refused", async () => { + // Review row on WriteToFileTool.ts:766. The marker map held ONE record per task, so the second + // refused rollback replaced the first: the record for path A was overwritten by path B, and a + // later write to A then found a refusal that named B, was not blocked by it, and called open() + // on the very document whose unapproved content the A record existed to protect. + mockCline.diffViewProvider.update.mockRejectedValueOnce( + Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValueOnce( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + await executeWriteFileTool({}, { isPartial: true }) + // A refused restore needs a live edit to snapshot, which is what open() leaves behind on a real + // stream; the double does not model that side effect, so it is set the way the sibling case sets + // it. + mockCline.diffViewProvider.isEditing = true + await executeWriteFileTool({}, { isPartial: true }) + mockCline.diffViewProvider.isEditing = false + const failureKey = `${mockCline.taskId}.${mockCline.instanceId}` + const otherFilePath = "test/other-file.txt" + expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) + + // Path B is now the edit the provider holds live: its stream fails and its own rollback is + // refused too. On the single-slot map this is where A's record disappears. + await executeWriteFileTool({ path: otherFilePath }, { isPartial: true }) + mockCline.diffViewProvider.update.mockRejectedValueOnce( + Object.assign(new Error("EROFS: read-only file system, write '/ro/other.py'"), { code: "EROFS" }), + ) + mockCline.diffViewProvider.revertChanges.mockRejectedValueOnce( + Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + ) + await executeWriteFileTool({}, { isPartial: true }) + mockCline.diffViewProvider.isEditing = false + + // The write back to A is the assertion that goes red on the single-slot map: the refusal A is + // refused against had been replaced by B, so A was allowed to open its own dirty document again. + // Its own recovery attempt reports that it restored nothing, which is what keeps A's record + // alive while the refusal is reported. + mockCline.diffViewProvider.revertChanges.mockResolvedValueOnce(false) + expect((writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length).toBe(2) + const openCallsBefore = mockCline.diffViewProvider.open.mock.calls.length + const reportsBefore = mockHandleError.mock.calls.filter(([context]) => context === "writing file").length + await executeWriteFileTool({}) + expect(mockCline.diffViewProvider.open.mock.calls.length).toBe(openCallsBefore) + expect(mockAskApproval).not.toHaveBeenCalled() + const reports = mockHandleError.mock.calls.filter(([context]) => context === "writing file") + expect(reports.length).toBe(reportsBefore + 1) + expect(reports[reports.length - 1]?.[1]).toBeInstanceOf(Error) + expect(String((reports[reports.length - 1]?.[1] as Error).message)).toContain("rollback failed") + + // Both records are still held, and lifting one does not lift the other: a recovery that reports + // the rollback ran clears only the record it recovered. + const records = writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[] + expect(records.length).toBe(2) + mockCline.diffViewProvider.revertChanges.mockResolvedValueOnce(true) + await executeWriteFileTool({}) + const remaining = writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[] + expect(remaining.length).toBe(1) + }) it("releases the refused-rollback marker when the retried rollback restores the buffer", async () => { // The other half of the contract: a rollback that reports completion lifts the refusal, and // the following write may open the diff view again. @@ -1351,7 +1410,7 @@ describe("writeToFileTool", () => { // path is refused against. expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) const failureKey = `${mockCline.taskId}.${mockCline.instanceId}` - const refusal = writeToFileTool["taskRollbackFailures"].get(failureKey) as + const refusal = writeToFileTool["taskRollbackFailures"].get(failureKey)?.[0] as { error: Error; context: { absolutePath: string } | undefined } | undefined // Compared against the same resolution the record was built with, not against the spec // constant: the constant hard-codes a drive (`C:\test\file.txt` on win32) while the record From 214209e0068921123149a2b7eef863dda42e9ef3 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:58:40 +0800 Subject: [PATCH 06/19] fix(u5): re-acquire the diff editor before reporting a rollback recovery attemptRollbackRecovery restored the recorded edit state but not activeDiffEditor, which revertChanges uses for all its document work: a create recovery unlinked the placeholder and returned true with the dirty buffer still open (the tool then cleared its fail-closed refusal and a later save recreated the file with unapproved content), and a modify recovery found no document, reported false, and could never lift the refusal on that path. Review row answered: DiffViewProvider.ts:614 (Major, both findings in that thread). Cases added at the provider layer. --- src/integrations/editor/DiffViewProvider.ts | 14 +++ .../editor/__tests__/DiffViewProvider.spec.ts | 87 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index be89014325..bce40065e4 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -611,6 +611,20 @@ export class DiffViewProvider { this.createdDirs = [...context.createdDirs] this.originalContent = context.originalContent this.isEditing = true + // Re-acquire the editor the rollback works through. reset() cleared activeDiffEditor, and + // revertChanges() does its document work from that field, so restoring only the metadata left two + // wrong reports: a new-file rollback deleted the placeholder, ran its own reset() and returned + // true while the dirty buffer was still open - so the caller cleared its fail-closed marker and a + // later save recreated the file with content the user never approved - and a modify rollback found + // no document, took the "editor vanished" return, and could never report a successful recovery on + // that path again. This is the recovery own attempt to get the buffer back, not a replay of + // open(): no directories are created, no placeholder is written and no content is applied here. + // If the file is gone by now the acquisition rejects, and the caller treats a rejection exactly + // like a refused restore - it keeps the refusal instead of clearing it on a rollback that did not + // happen. + if (!this.activeDiffEditor) { + this.activeDiffEditor = await this.openDiffEditor() + } return this.revertChanges() } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index b980733db0..6402c1f553 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2457,4 +2457,91 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).toHaveBeenCalled() }) }) + + describe("attemptRollbackRecovery() re-acquires the buffer it is rolling back", () => { + // The recovery entry point exists because reset() has already forgotten the edit: a refused + // rollback reports itself, the tool keeps the captured context across that reset, and the context + // is handed back later. Installing only the recorded metadata left revertChanges() free to report + // a result the caller could not act on, in both edit types, which is the pair of wrong reports the + // two cases below pin. + const recoveryContextFor = (relPath: string, editType: "create" | "modify", createdDirs: string[] = []) => ({ + relPath, + absolutePath: `${mockCwd}/${relPath}`, + editType, + createdDirs, + originalContent: editType === "modify" ? "on-disk original" : "", + }) + + const simulateReset = () => { + Object.assign(diffViewProvider, { + isEditing: false, + relPath: undefined, + editType: undefined, + createdDirs: [], + originalContent: undefined, + activeDiffEditor: undefined, + }) + // Stubbed to the editor the real acquisition would hand back for this path. openDiffEditor() is + // covered by the open() suite, and leaving it live here would make the case depend on tab + // plumbing and a ten-second acquisition timeout instead of on the recovery contract. + const document = makeTextDocument({ + uri: makeUri(`${mockCwd}/mock-recovery-target.ts`), + getText: vi.fn().mockReturnValue("streamed, never approved"), + isDirty: true, + }) + const editor = makeTextEditor({ document }) + diffViewProvider["openDiffEditor"] = vi.fn().mockResolvedValue(editor) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + return editor + } + + it("discards the dirty buffer of a refused create instead of reporting a clean rollback", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + simulateReset() + const discardFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["discardFileTab"] = discardFileTab + + await expect( + diffViewProvider.attemptRollbackRecovery( + recoveryContextFor(relPath, "create", [`${mockCwd}/new-parent`]), + ), + ).resolves.toBe(true) + + // The buffer work has to happen before the placeholder goes away. With metadata-only recovery + // revertChanges() skipped the document branch, unlinked the placeholder, deleted the created + // directory and still returned true: the tool read that as a restored buffer, cleared its + // fail-closed refusal, and the next open() of this path saved content the user never approved. + expect(diffViewProvider["openDiffEditor"]).toHaveBeenCalledTimes(1) + expect(discardFileTab).toHaveBeenCalledWith(absolutePath) + expect(fs.unlink).toHaveBeenCalledWith(absolutePath) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/new-parent`) + // The recovery leaves the provider as clean as the rollback it reports. + expect(diffViewProvider["isEditing"]).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + }) + + it("restores and saves an existing file instead of reporting that there was nothing to restore", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const editor = simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + + await expect(diffViewProvider.attemptRollbackRecovery(recoveryContextFor(relPath, "modify"))).resolves.toBe( + true, + ) + + // Without the re-acquisition this branch found no document, took the "editor vanished" return + // false, and every later retry on the same path reported the same nothing: the refusal could + // never be lifted, so the path stayed blocked for the rest of the task even though the file + // was sitting there, dirty, waiting to be restored. + expect(restorePreStreamBuffer).toHaveBeenCalledWith(absolutePath, editor.document, "on-disk original") + expect(saveBufferClean).toHaveBeenCalledWith(absolutePath, editor.document) + }) + }) }) From ccb1b5f4f804cb3a8436a3c63911d22bcb597053 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:25:37 +0800 Subject: [PATCH 07/19] test(writeToFileTool): pin refused-rollback retention at the storage the review row names The case added in the previous commit went red on the required leg platform-unit-test (ubuntu-latest) (check-run 128569363688): "AssertionError: expected 1 to be 2" at writeToFileTool.spec.ts:1331 under "pnpm run test:coverage:core", the command that leg runs. It reproduces locally under that same command (1 failed | 197 passed files, 1 failed | 4293 passed | 9 skipped tests) and it fails in isolation on this head, so it is neither queue drift across tests nor a preceding test's record: the construction was wrong. Measured with a probe assertion placed right after the second partial delta, the map held one record naming path A, open() had never been called, and the update()/revertChanges() counts stayed at one: the refusal gate suppresses the following partial delta before the provider is touched. So the four-execute() scenario was testing this file's harness suppression order, not the per-path retention rule the review row on WriteToFileTool.ts:766 is about. Replaced it with a case that drives that storage directly - recordRollbackFailure, refusalForPath, forgetRollbackFailure - over two path-scoped records, same-target replacement, the task-wide (context-less) record and what it subsumes, and lifting one record without lifting the other. Spec only: no production change here. Against the parent production 6147c93e5 the case fails with "TypeError: writeToFileTool.recordRollbackFailure is not a function", which is the control. This push is also the re-trigger attempt for the required status-check legs: this account cannot rerun an Actions run (POST rerun-failed-jobs answers 403 "Must have admin rights to Repository"). --- .../tools/__tests__/writeToFileTool.spec.ts | 128 +++++++++++------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index d3697dd764..5e0c6af503 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -9,6 +9,7 @@ import { getReadablePath } from "../../../utils/path" import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import type { Task } from "../../task/Task" import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { @@ -1289,65 +1290,88 @@ describe("writeToFileTool", () => { expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) }) - it("keeps the refusal on the first path when a second path has its own rollback refused", async () => { - // Review row on WriteToFileTool.ts:766. The marker map held ONE record per task, so the second - // refused rollback replaced the first: the record for path A was overwritten by path B, and a - // later write to A then found a refusal that named B, was not blocked by it, and called open() - // on the very document whose unapproved content the A record existed to protect. - mockCline.diffViewProvider.update.mockRejectedValueOnce( - Object.assign(new Error("EROFS: read-only file system, write '/ro/test.py'"), { code: "EROFS" }), - ) - mockCline.diffViewProvider.revertChanges.mockRejectedValueOnce( - Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), - ) - await executeWriteFileTool({}, { isPartial: true }) - // A refused restore needs a live edit to snapshot, which is what open() leaves behind on a real - // stream; the double does not model that side effect, so it is set the way the sibling case sets - // it. - mockCline.diffViewProvider.isEditing = true - await executeWriteFileTool({}, { isPartial: true }) - mockCline.diffViewProvider.isEditing = false + it("keeps one refused-rollback record per affected path instead of one per task", async () => { + // Review row on WriteToFileTool.ts:766. The marker map held ONE record per task, so a refused + // rollback on path B replaced the record for path A: a later write to A was then refused against + // B's record, which did not block it, and open() saved the buffer A's record existed to protect. + // This drives the storage the row names directly. Building the same scenario through four + // executeWriteFileTool() calls does not work in this file's harness and that is worth saying out + // loud: measured with the provider double and the gate in place, a second partial delta aimed at + // another path produced no open() at all and no second update() (opens=[], update calls stayed at + // one), because the refusal gate suppresses the following delta before the provider is touched. + // So the four-call version was testing the harness's suppression order, not the retention rule the + // row is about, and it went red on the ubuntu leg for that reason. + const task = mockCline as unknown as Task const failureKey = `${mockCline.taskId}.${mockCline.instanceId}` const otherFilePath = "test/other-file.txt" - expect(writeToFileTool["taskRollbackFailures"].size).toBe(1) - - // Path B is now the edit the provider holds live: its stream fails and its own rollback is - // refused too. On the single-slot map this is where A's record disappears. - await executeWriteFileTool({ path: otherFilePath }, { isPartial: true }) - mockCline.diffViewProvider.update.mockRejectedValueOnce( - Object.assign(new Error("EROFS: read-only file system, write '/ro/other.py'"), { code: "EROFS" }), + const contextFor = (relPath: string) => ({ + relPath, + absolutePath: realPathResolve("/", relPath), + editType: "create" as const, + createdDirs: [], + originalContent: "", + }) + const recordA = { error: new Error("EPERM: rollback refused on A"), context: contextFor(testFilePath) } + const recordB = { error: new Error("EPERM: rollback refused on B"), context: contextFor(otherFilePath) } + writeToFileTool["recordRollbackFailure"](task, recordA) + writeToFileTool["recordRollbackFailure"](task, recordB) + + const records = writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as { + error: Error + context: { absolutePath: string } + }[] + expect(records.length).toBe(2) + expect(writeToFileTool["refusalForPath"](task, testFilePath)?.error.message).toBe( + "EPERM: rollback refused on A", ) - mockCline.diffViewProvider.revertChanges.mockRejectedValueOnce( - Object.assign(new Error("EACCES: rollback failed"), { code: "EACCES" }), + expect(writeToFileTool["refusalForPath"](task, otherFilePath)?.error.message).toBe( + "EPERM: rollback refused on B", ) - await executeWriteFileTool({}, { isPartial: true }) - mockCline.diffViewProvider.isEditing = false + expect(writeToFileTool["refusalForPath"](task, "test/unrelated-file.txt")).toBeUndefined() - // The write back to A is the assertion that goes red on the single-slot map: the refusal A is - // refused against had been replaced by B, so A was allowed to open its own dirty document again. - // Its own recovery attempt reports that it restored nothing, which is what keeps A's record - // alive while the refusal is reported. - mockCline.diffViewProvider.revertChanges.mockResolvedValueOnce(false) + // A refusal for a target already refused replaces that target's record - same buffer, and the + // recovery context that matters is the newest one - and leaves the other path's refusal in place. + writeToFileTool["recordRollbackFailure"](task, { + error: new Error("EPERM: rollback refused on A again"), + context: contextFor(testFilePath), + }) expect((writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length).toBe(2) - const openCallsBefore = mockCline.diffViewProvider.open.mock.calls.length - const reportsBefore = mockHandleError.mock.calls.filter(([context]) => context === "writing file").length - await executeWriteFileTool({}) - expect(mockCline.diffViewProvider.open.mock.calls.length).toBe(openCallsBefore) - expect(mockAskApproval).not.toHaveBeenCalled() - const reports = mockHandleError.mock.calls.filter(([context]) => context === "writing file") - expect(reports.length).toBe(reportsBefore + 1) - expect(reports[reports.length - 1]?.[1]).toBeInstanceOf(Error) - expect(String((reports[reports.length - 1]?.[1] as Error).message)).toContain("rollback failed") - - // Both records are still held, and lifting one does not lift the other: a recovery that reports - // the rollback ran clears only the record it recovered. - const records = writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[] - expect(records.length).toBe(2) - mockCline.diffViewProvider.revertChanges.mockResolvedValueOnce(true) - await executeWriteFileTool({}) - const remaining = writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[] - expect(remaining.length).toBe(1) + expect(writeToFileTool["refusalForPath"](task, testFilePath)?.error.message).toBe( + "EPERM: rollback refused on A again", + ) + + // Lifting one record must not lift another: clearing B's refusal leaves A's in place, and a write + // to A is still refused while nothing refuses B any more. + writeToFileTool["forgetRollbackFailure"](task, recordB) + expect((writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length).toBe(1) + expect(writeToFileTool["refusalForPath"](task, testFilePath)?.error.message).toBe( + "EPERM: rollback refused on A again", + ) + expect(writeToFileTool["refusalForPath"](task, otherFilePath)).toBeUndefined() + + // A refusal captured without a live edit cannot name a target, so it keeps the task-wide reach the + // marker shipped with: it answers for every path, including one nothing else refused, and it + // subsumes the path-scoped records it is recorded beside, because while a task-wide refusal stands + // every path is refused anyway and those records would add nothing to the decision. Lifting it + // clears the task-wide refusal instead of leaving a record that answers for nothing. + const taskWide = { error: new Error("EPERM: no live edit to snapshot"), context: undefined } + writeToFileTool["recordRollbackFailure"](task, taskWide) + expect( + (writeToFileTool["taskRollbackFailures"] as unknown as Map).get(failureKey) === + undefined + ? "gone" + : (writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length, + ).toBe(1) + expect(writeToFileTool["refusalForPath"](task, "test/unrelated-file.txt")?.error.message).toBe( + "EPERM: no live edit to snapshot", + ) + expect(writeToFileTool["refusalForPath"](task, testFilePath)?.error.message).toBe( + "EPERM: no live edit to snapshot", + ) + writeToFileTool["forgetRollbackFailure"](task, taskWide) + expect(writeToFileTool["taskRollbackFailures"].get(failureKey)).toBeUndefined() }) + it("releases the refused-rollback marker when the retried rollback restores the buffer", async () => { // The other half of the contract: a rollback that reports completion lifts the refusal, and // the following write may open the diff view again. From 21582ebd6852859d12ce175be06f60e11eeb2d9b Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:45:52 +0800 Subject: [PATCH 08/19] fix(u5): keep the task-wide refusal from deleting path-scoped ones A refusal captured without a live edit cannot name a path, so it answers for every path that has no refusal of its own. While it stands it outranks the path-scoped records; that ranking is not licence to delete them. A task-wide refusal can be lifted by one retried rollback, and the records it removed with it were earned by rollbacks that are still unresolved: forgetting the task-wide marker silently unblocked those paths and a later open() saved their dirty, never-approved buffers. The storage now keeps every path-scoped record and drops only other target-less ones, which is the same distinction the lookup already makes. The spec drives the storage directly for the same reason the per-path case does: the harness suppresses the second partial delta before the provider is touched, so a call-level version tests the harness rather than the retention rule. Gates (node v22.23.4): vitest.core.config.ts writeToFileTool.spec.ts 62 passed | 5 skipped; tsc --noEmit clean; eslint --max-warnings=0 clean; prettier --check clean. --- src/core/tools/WriteToFileTool.ts | 17 +++++++++++---- .../tools/__tests__/writeToFileTool.spec.ts | 21 +++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 6a5f06c9b9..76ad227dba 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -105,7 +105,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * The value is a list rather than a single record because the gate is path-scoped. With one slot * per task, a refused rollback on path B replaced the record for path A, so path A lost the only * thing that was keeping its dirty, unapproved buffer from being saved by a later open() - the - * gate spoke about "this path", while the storage could remember only one path at a time. + * gate spoke about "this path", while the storage could remember only one path at a time. The + * same rule applies inside the list: a record that cannot name a path outranks the + * path-scoped ones while it stands, and that is not licence to delete them. A task-wide + * refusal can be lifted by one retried rollback, and the refusals it deleted with it + * were earned by rollbacks that are still unresolved. */ private taskRollbackFailures = new Map() @@ -122,7 +126,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { * is added beside it rather than replacing it; a refusal for the SAME target replaces the older * record, because it describes the same buffer and the recovery context that matters is the newest * one. A record whose capture found no live edit cannot name a target: it keeps the task-wide - * reach it shipped with, and only another refusal that likewise cannot name one replaces it. + * reach it shipped with - it answers for every path that has no refusal of its own - and + * only another refusal that likewise cannot name one replaces it. It outranks the + * path-scoped records for the paths they do not speak for; it does not delete them, + * because lifting the task-wide refusal must not silently lift a path whose own + * rollback is still refused. */ private recordRollbackFailure(task: Task, failure: TaskRollbackFailure): void { const key = this.getPartialStreamFailureKey(task) @@ -130,7 +138,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const target = failure.context?.absolutePath const kept = target ? existing.filter((record) => record.context?.absolutePath !== target) - : existing.filter((record) => !record.context) + : existing.filter((record) => record.context) this.taskRollbackFailures.set(key, [...kept, failure]) } @@ -148,7 +156,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { /** * Drop one recorded refusal once its own buffer is confirmed restored, leaving every other - * path's refusal in place: lifting A's block must not silently lift B's. + * refusal in place: lifting A's block must not silently lift B's, and lifting a + * task-wide refusal must not silently lift a path that earned its own. */ private forgetRollbackFailure(task: Task, failure: TaskRollbackFailure): void { const key = this.getPartialStreamFailureKey(task) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 5e0c6af503..710dd14438 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -1351,25 +1351,24 @@ describe("writeToFileTool", () => { // A refusal captured without a live edit cannot name a target, so it keeps the task-wide reach the // marker shipped with: it answers for every path, including one nothing else refused, and it - // subsumes the path-scoped records it is recorded beside, because while a task-wide refusal stands - // every path is refused anyway and those records would add nothing to the decision. Lifting it - // clears the task-wide refusal instead of leaving a record that answers for nothing. + // outranks the path-scoped records it is recorded beside: while a task-wide refusal stands, + // every path is refused anyway and those records add nothing to the decision. It does not delete + // them, because lifting the task-wide refusal must not lift a path whose own rollback is refused. const taskWide = { error: new Error("EPERM: no live edit to snapshot"), context: undefined } writeToFileTool["recordRollbackFailure"](task, taskWide) - expect( - (writeToFileTool["taskRollbackFailures"] as unknown as Map).get(failureKey) === - undefined - ? "gone" - : (writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length, - ).toBe(1) + expect((writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length).toBe(2) expect(writeToFileTool["refusalForPath"](task, "test/unrelated-file.txt")?.error.message).toBe( "EPERM: no live edit to snapshot", ) expect(writeToFileTool["refusalForPath"](task, testFilePath)?.error.message).toBe( - "EPERM: no live edit to snapshot", + "EPERM: rollback refused on A again", ) writeToFileTool["forgetRollbackFailure"](task, taskWide) - expect(writeToFileTool["taskRollbackFailures"].get(failureKey)).toBeUndefined() + expect((writeToFileTool["taskRollbackFailures"].get(failureKey) as unknown as unknown[]).length).toBe(1) + expect(writeToFileTool["refusalForPath"](task, testFilePath)?.error.message).toBe( + "EPERM: rollback refused on A again", + ) + expect(writeToFileTool["refusalForPath"](task, "test/unrelated-file.txt")).toBeUndefined() }) it("releases the refused-rollback marker when the retried rollback restores the buffer", async () => { From 7dd30772e880d36c8dd3646542344031d36f34d1 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:46:22 +0800 Subject: [PATCH 09/19] fix(u5): roll the install back when the recovery cannot re-acquire the buffer A recovery that cannot open the buffer again keeps the caller's refusal, because the caller treats a rejection exactly like a refused restore. The install did not keep the provider: the edit metadata and isEditing stayed set with no editor, and the next attempt walked past the acquisition into a revertChanges() that had no document to work from. In the create shape that meant deleting the placeholder, running its own reset() and reporting a clean rollback with the dirty, never-approved buffer still open; in the modify shape it meant the "editor vanished" return, so the refusal could never be lifted on a path whose file was sitting there waiting to be restored. The edit fields are now captured before the install and put back when the acquisition rejects, before rethrowing, and the spec makes two attempts: the first must reject and leave the provider exactly as it was found, the second must reach the acquisition again and actually run the rollback. A separate assertion records, from inside the unlink double, that the document discard has already been invoked before the placeholder is deleted - the ordering the review row asks the test to name, which the end-state assertions alone cannot distinguish. Gates (node v22.23.4): vitest.misc.config.ts DiffViewProvider.spec.ts 95 passed; tsc --noEmit clean; eslint --max-warnings=0 clean; prettier --check clean. Negative control with the restore removed (git show HEAD of the file, written back and compared byte-for-byte): 1 failed | 94 passed. Same window exists in the sibling branch copy inside open(); it is not fixed here. --- src/integrations/editor/DiffViewProvider.ts | 16 ++++- .../editor/__tests__/DiffViewProvider.spec.ts | 66 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bce40065e4..566f6c769a 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -606,6 +606,13 @@ export class DiffViewProvider { } return this.revertChanges() } + // Captured so a failed re-acquisition can put the provider back exactly as it was: an + // acquisition that rejects must not leave a half-installed edit behind. + const wasEditing = this.isEditing + const previousRelPath = this.relPath + const previousEditType = this.editType + const previousCreatedDirs = this.createdDirs + const previousOriginalContent = this.originalContent this.relPath = context.relPath this.editType = context.editType this.createdDirs = [...context.createdDirs] @@ -622,8 +629,15 @@ export class DiffViewProvider { // If the file is gone by now the acquisition rejects, and the caller treats a rejection exactly // like a refused restore - it keeps the refusal instead of clearing it on a rollback that did not // happen. - if (!this.activeDiffEditor) { + try { this.activeDiffEditor = await this.openDiffEditor() + } catch (acquisitionError: unknown) { + this.isEditing = wasEditing + this.relPath = previousRelPath + this.editType = previousEditType + this.createdDirs = previousCreatedDirs + this.originalContent = previousOriginalContent + throw acquisitionError } return this.revertChanges() } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 6402c1f553..fa24791e6b 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2500,6 +2500,21 @@ describe("DiffViewProvider", () => { const relPath = "mock-recovery-target.ts" const absolutePath = `${mockCwd}/${relPath}` simulateReset() + // The review asks this test to say which side of the discard the unlink may sit on, so the + // facts are sampled from inside the unlink itself: that call is the moment the ordering is + // decided, and a test that only inspects the finished rollback cannot tell the two awaits in + // revertChanges() apart. The document discard has to be recorded before the placeholder goes + // away - discarding the buffer saves the streamed content back to disk, and unlinking first + // deletes the file under a buffer that is still dirty. + const discardCallsBeforeFirstUnlink: number[] = [] + const unlinkProbe = vi.mocked(fs.unlink) + unlinkProbe.mockImplementation(async (pathToUnlink: Parameters[0]): Promise => { + if (String(pathToUnlink) === absolutePath) { + discardCallsBeforeFirstUnlink.push(discardFileTab.mock.calls.length) + } + return unlinkProbe.mockResolvedValue(undefined)(pathToUnlink) + }) + const discardFileTab = vi.fn().mockResolvedValue(undefined) diffViewProvider["discardFileTab"] = discardFileTab @@ -2517,11 +2532,62 @@ describe("DiffViewProvider", () => { expect(discardFileTab).toHaveBeenCalledWith(absolutePath) expect(fs.unlink).toHaveBeenCalledWith(absolutePath) expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/new-parent`) + + // Recorded by the unlink double at its own first call for this path: the document discard had + // already been invoked. A rollback that unlinked the placeholder before discarding the buffer + // - the swap this row guards against - records 0 here and fails this assertion while the + // end-state assertions around it stay green. + expect(discardCallsBeforeFirstUnlink).toEqual([1]) // The recovery leaves the provider as clean as the rollback it reports. expect(diffViewProvider["isEditing"]).toBe(false) expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() }) + it("restores the install when the recovery cannot re-acquire the buffer", async () => { + const relPath = "mock-recovery-target." + const absolutePath = `${mockCwd}/${relPath}` + const editor = simulateReset() + const gone = new Error("EPERM: the file is gone before the buffer could be reopened") + const acquisition = vi.fn().mockRejectedValueOnce(gone).mockResolvedValue(editor) + diffViewProvider["openDiffEditor"] = acquisition + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + + // A file that is gone by the time the retry runs is the shape this branch has to survive: the + // acquisition rejects, and the caller reads the rejection as a refused restore. What used to + // survive the rejection was the install itself - isEditing true with no editor - and the next + // attempt then walked past the acquisition into a revertChanges() that found no document, and + // in the create shape it deleted the placeholder, ran its own cleanup and reported a clean + // rollback with the dirty buffer still open. Rethrowing only helps if the provider is put + // back exactly as it was found, which is what the assertions after the rejection check. + await expect(diffViewProvider.attemptRollbackRecovery(recoveryContextFor(relPath, "modify"))).rejects.toBe( + gone, + ) + + expect(acquisition).toHaveBeenCalledTimes(1) + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + expect(diffViewProvider["isEditing"]).toBe(false) + expect(diffViewProvider["relPath"]).toBeUndefined() + expect(diffViewProvider["editType"]).toBeUndefined() + expect(diffViewProvider["createdDirs"]).toEqual([]) + expect(diffViewProvider["originalContent"]).toBeUndefined() + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + + // The caller keeps its refusal until a rollback reports completion, so the next attempt has to + // reach the acquisition again. A half-installed provider would report false there without + // trying, and the path would stay refused for the rest of the task with its buffer on screen. + await expect(diffViewProvider.attemptRollbackRecovery(recoveryContextFor(relPath, "modify"))).resolves.toBe( + true, + ) + expect(acquisition).toHaveBeenCalledTimes(2) + expect(restorePreStreamBuffer).toHaveBeenCalledWith(absolutePath, editor.document, "on-disk original") + expect(saveBufferClean).toHaveBeenCalledWith(absolutePath, editor.document) + }) + it("restores and saves an existing file instead of reporting that there was nothing to restore", async () => { const relPath = "mock-recovery-target.ts" const absolutePath = `${mockCwd}/${relPath}` From 7a1a710cf37c9eed83afc2a4d2d07af8d086184d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:53:39 +0800 Subject: [PATCH 10/19] test(tools): name the abort listener the cancelled-stream release must drop The getState() cancellation case asserted that task.off() was called with expect.any(Function). A function-shaped matcher cannot fail: it passes when the release deregisters a different callback, and it passes when the listener was never registered at all, which is the half of the contract this case exists for. The state the case seeds is also the state the listener is attached to, so the reference is available - capture it, pin the registration before the release, and assert the release against the same value. Measured with git grep -n -e expect.any(Function) over src at this head: 57 occurrences in 27 files, one of them on an off() call (this line) and none on a once() call; at the merge base 37 occurrences in 18 files. The matcher is common in this tree, the listener-removal shape was not, so this settles the row rather than generalising it. Gates (node v22.23.4): vitest.core.config.ts writeToFileTool-partial-state-cleanup.spec.ts 10 passed. Negative control with task.once(RooCodeEventName.TaskAborted, state.abortCleanup) deleted from WriteToFileTool.ts (git show HEAD of the file, written back and compared byte-for-byte): 4 failed | 6 passed - this case fails on both halves of the pairing, which the class matcher passed silently. --- ...writeToFileTool-partial-state-cleanup.spec.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 92fbfd14a2..da025bf35c 100644 --- a/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool-partial-state-cleanup.spec.ts @@ -226,7 +226,19 @@ describe("WriteToFileTool partial-delta cancellation", () => { // suppressed-focus branch release the state and return on its own, which proves nothing // about the check under test.) task.diffViewProvider.editType = undefined - seedStablePath(task as unknown as Task) + // The release assertion below needs the listener identity, not a function-shaped matcher. + // Seeding through the call the case already makes is enough: it returns the per-task state + // the abort listener is attached to, so no extra registration is invented here. + const state = seedStablePath(task as unknown as Task) + const releasedAbortCleanup = state.abortCleanup + // The registration side of the pairing, asserted before the release. This is the only + // listener-removal assertion in the tree that used a class matcher (measured with + // git grep -n -e expect.any(Function) over src: 57 occurrences in 27 files, one of them on + // an off() call, and zero on a once() call). The class version passes when nothing was ever + // registered, which is the half of the contract this case exists for: the per-task state + // binds its abortCleanup callback, handlePartial registers that reference through task.once, + // and this early-exit path has to deregister the same one. + expect(task.once).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, releasedAbortCleanup) task.providerRef.deref = () => ({ getState: async () => { task.abort = true @@ -238,7 +250,7 @@ describe("WriteToFileTool partial-delta cancellation", () => { expect(vi.mocked(fileExistsAtPath)).not.toHaveBeenCalled() expect(writeToFileTool["taskPartialStreamState"].size).toBe(0) - expect(task.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, expect.any(Function)) + expect(task.off).toHaveBeenCalledWith(RooCodeEventName.TaskAborted, releasedAbortCleanup) expect(task.ask).not.toHaveBeenCalled() }) From 7bf85730d514c4420e2f41b8f94955c4cbe23936 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:19:32 +0800 Subject: [PATCH 11/19] test(u5): hold the discard open to say which side of it the unlink sits on The create-recovery case now reads the ordering the way the review row names it: the document discard is held unresolved, the placeholder is asserted still on disk inside that window, the promise is resolved by the test rather than by the production await, and only then is the deletion asserted. A rollback that deleted the file before discarding the buffer fails inside the window instead of after the fact. The earlier version sampled the ordering from inside the unlink double. It was sound and its control passed, but it came from a wrong diagnosis: I had awaited the recovery while the discard was still pending, which is what deadlocked the case (reproduced as a stack-trace error at about 20.0 s, and it passed under --testTimeout=9000, which is how the cause was identified). Kicking the recovery off without awaiting it makes the shape the row asked for work, and it no longer mutates the fs double for the rest of the file. Gates and control at node v22.23.4: vitest.misc.config.ts against this spec 95 passed (95), status 0; with the two awaits swapped in the component the case fails at spec :2525 ('expected "vi.fn()" to not be called with arguments'), 4 failed | 91 passed, status 1; tsc --noEmit -p tsconfig.json 0 errors; eslint --max-warnings=0 --no-cache 0; prettier unchanged. The component was written back byte-for-byte from git show of the committed tree after the control. Not pushed with 7a1a710cf: this round's single push was spent, so this commit is the head of the next round's push. --- .../editor/__tests__/DiffViewProvider.spec.ts | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index fa24791e6b..1a698aa64f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2500,29 +2500,32 @@ describe("DiffViewProvider", () => { const relPath = "mock-recovery-target.ts" const absolutePath = `${mockCwd}/${relPath}` simulateReset() - // The review asks this test to say which side of the discard the unlink may sit on, so the - // facts are sampled from inside the unlink itself: that call is the moment the ordering is - // decided, and a test that only inspects the finished rollback cannot tell the two awaits in - // revertChanges() apart. The document discard has to be recorded before the placeholder goes - // away - discarding the buffer saves the streamed content back to disk, and unlinking first - // deletes the file under a buffer that is still dirty. - const discardCallsBeforeFirstUnlink: number[] = [] - const unlinkProbe = vi.mocked(fs.unlink) - unlinkProbe.mockImplementation(async (pathToUnlink: Parameters[0]): Promise => { - if (String(pathToUnlink) === absolutePath) { - discardCallsBeforeFirstUnlink.push(discardFileTab.mock.calls.length) - } - return unlinkProbe.mockResolvedValue(undefined)(pathToUnlink) - }) - - const discardFileTab = vi.fn().mockResolvedValue(undefined) + // The row asks this case to name the side of the discard the unlink may sit on, so the discard + // is held unresolved and the placeholder is asserted still on disk inside that window. The + // promise is resolved by the test and never by the production await, so the rollback proceeds + // only after the ordering question has been answered. + let settleDiscard: (() => void) | undefined + const discardHeld = new Promise((resolve) => { + settleDiscard = resolve + }) + const discardFileTab = vi.fn(() => discardHeld) diffViewProvider["discardFileTab"] = discardFileTab - await expect( - diffViewProvider.attemptRollbackRecovery( - recoveryContextFor(relPath, "create", [`${mockCwd}/new-parent`]), - ), - ).resolves.toBe(true) + const recovery = diffViewProvider.attemptRollbackRecovery( + recoveryContextFor(relPath, "create", [`${mockCwd}/new-parent`]), + ) + + // Let the recovery run to its await on the held discard, then answer the ordering question while + // that await is still pending: a rollback that unlinked the placeholder before discarding the + // buffer has already deleted the file by this point and fails here, while the assertions that + // describe the finished rollback stay green in either order. + await vi.waitFor(() => { + expect(discardFileTab).toHaveBeenCalledWith(absolutePath) + }) + expect(fs.unlink).not.toHaveBeenCalledWith(absolutePath) + settleDiscard?.() + + await expect(recovery).resolves.toBe(true) // The buffer work has to happen before the placeholder goes away. With metadata-only recovery // revertChanges() skipped the document branch, unlinked the placeholder, deleted the created @@ -2532,12 +2535,6 @@ describe("DiffViewProvider", () => { expect(discardFileTab).toHaveBeenCalledWith(absolutePath) expect(fs.unlink).toHaveBeenCalledWith(absolutePath) expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/new-parent`) - - // Recorded by the unlink double at its own first call for this path: the document discard had - // already been invoked. A rollback that unlinked the placeholder before discarding the buffer - // - the swap this row guards against - records 0 here and fails this assertion while the - // end-state assertions around it stay green. - expect(discardCallsBeforeFirstUnlink).toEqual([1]) // The recovery leaves the provider as clean as the rollback it reports. expect(diffViewProvider["isEditing"]).toBe(false) expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() From 22969c635451555aeeb958a6aa71f475749f327d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:38:06 +0800 Subject: [PATCH 12/19] fix(u5): stop a timed-out acquisition from opening a diff nobody is waiting for openDiffEditor() races a ten-second timer against a showTextDocument it cannot cancel. The timeout rejected the acquisition and disposed the listeners, but nothing stopped the follow-up callback from running the diff command anyway: the tab opened after the caller had already been told the acquisition failed, with no listener left to adopt it, and the original side of that diff was built from whatever the provider field held at that moment rather than the content the acquisition was asked to compare against. That second half matters more now than it did, because a failed recovery re-acquisition restores the previous baseline: a late callback used to see the captured one and now sees the restored one. The command is skipped once the acquisition has abandoned the path, and the compared content is captured when the acquisition starts. Behaviour is unchanged for an acquisition that settles in time: the captured value is the live value there. The capture is not extended into the buffer restore, which still reads the live field - that is a second decision this row did not ask for, and it is said in-thread rather than slipped in. Gates and controls at node v22.23.4: this spec 97 passed (97), status 0; the whole misc layer 117 passed | 1 skipped files, 2118 passed | 13 skipped (2131), status 0; vitest.core.config.ts 198 files 4294 passed | 9 skipped, status 0; tsc --noEmit -p tsconfig.json 0 errors; eslint --max-warnings=0 --no-cache over src status 0; prettier --check clean; suppression counts unchanged at 265 files / 2870 entries. Controls: deleting the abandon check makes the timeout case fail ('expected vi.fn() to not be called at all, but actually been called 1 times', status 1), and reading the live field instead of the captured one makes the baseline case fail (expected ['c3dhcHBlZCBtaWQtZmxpZ2h0'] to deeply equal ['Y2FwdHVyZWQgYXQgYWNxdWlzaXRpb24='], status 1). The component was written back byte-for-byte after each control and re-run green. Not pushed with 7a1a710cf: that round's single push was spent, and the Lead fixed 7bf85730d as the next head, so which head carries this commit is the Lead's call. --- src/integrations/editor/DiffViewProvider.ts | 16 +- .../editor/__tests__/DiffViewProvider.spec.ts | 173 ++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 566f6c769a..821cb241bf 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1176,6 +1176,13 @@ export class DiffViewProvider { const fileName = path.basename(uri.fsPath) const fileExists = this.editType === "modify" const DIFF_EDITOR_TIMEOUT = 10_000 // ms + // The diff original side is built when the pre-open settles below, which can be after this + // promise already settled. Captured at acquisition time so a late callback cannot compare against + // a baseline some other flow installed in the meantime. + const capturedOriginalContent = this.originalContent + // Set when this acquisition gives up. The timeout rejects the promise but cannot cancel + // showTextDocument, so without this the diff command still runs for a caller that left. + let acquisitionAbandoned = false let timeoutId: NodeJS.Timeout | undefined const disposables: vscode.Disposable[] = [] @@ -1191,6 +1198,7 @@ export class DiffViewProvider { // Set timeout for the entire operation timeoutId = setTimeout(() => { + acquisitionAbandoned = true cleanup() reject( new Error( @@ -1240,11 +1248,17 @@ export class DiffViewProvider { vscode.window .showTextDocument(uri, { preview: false, viewColumn: vscode.ViewColumn.Active, preserveFocus: true }) .then(() => { + if (acquisitionAbandoned) { + // Nobody is waiting for this acquisition any more. The tab would outlive every listener it + // needed (the disposables went with the timeout) and the caller has already put the provider + // back the way it found it, so the diff would compare against somebody else baseline. + return + } // Execute the diff command after ensuring the file is open as text return vscode.commands.executeCommand( "vscode.diff", vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({ - query: Buffer.from(this.originalContent ?? "").toString("base64"), + query: Buffer.from(capturedOriginalContent ?? "").toString("base64"), }), uri, `${fileName}: ${fileExists ? `${DIFF_VIEW_LABEL_CHANGES}` : "New File"} (Editable)`, diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 1a698aa64f..27b7f153da 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2607,4 +2607,177 @@ describe("DiffViewProvider", () => { expect(saveBufferClean).toHaveBeenCalledWith(absolutePath, editor.document) }) }) + // The acquisition inside openDiffEditor() races a ten-second timer against a showTextDocument it cannot + // cancel. Both cases below are about what that leaves behind: a diff nobody is waiting for, and a diff + // original side built from whatever the field holds when the callback finally runs. + describe("openDiffEditor() gives up on a diff nobody is waiting for", () => { + // Local to this suite on purpose: the fixture here is the real acquisition, while the recovery suite + // above stubs openDiffEditor() out to keep its cases off the tab plumbing and the timer. + const recoveryFor = (relPath: string, editType: "create" | "modify") => ({ + relPath, + absolutePath: `${mockCwd}/${relPath}`, + editType, + createdDirs: [], + originalContent: editType === "modify" ? "captured at acquisition" : "", + }) + + const wireAcquisition = (editor: vscode.TextEditor) => { + // A tab already open on this path takes the activate-existing-diff fast path inside + // openDiffEditor() and skips the acquisition this suite is about, so the tab list is pinned empty + // here instead of inherited from whichever suite ran last. + Object.defineProperty(vscode.window.tabGroups, "all", { + get: (): vscode.TabGroup[] => [], + configurable: true, + }) + let settleShow: (() => void) | undefined + // Recorded so a case can prove the pre-open really did settle late; otherwise the assertion that + // nothing opened would pass on an acquisition that never reached its follow-up callback. + let showSettled = false + const showDeferred = new Promise((resolve) => { + settleShow = () => { + showSettled = true + resolve(editor) + } + }) + vi.mocked(vscode.window.showTextDocument).mockImplementation(() => showDeferred) + let documentOpened: ((document: vscode.TextDocument) => unknown) | undefined + const disposeOpenDocument = vi.fn() + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + documentOpened = callback + return { dispose: disposeOpenDocument } + }) + const disposeVisibleEditors = vi.fn() + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: disposeVisibleEditors }) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + // Accessors rather than the values: the component fills both in after this returns, so a snapshot + // would hand back undefined and a case would pass without ever reaching the acquisition. + return { + settleShow: () => settleShow?.(), + showSettled: () => showSettled, + documentOpened: (document: vscode.TextDocument) => documentOpened?.(document), + registered: () => documentOpened !== undefined, + disposeOpenDocument, + disposeVisibleEditors, + } + } + + const inactiveProvider = (originalContent: string | undefined) => { + Object.assign(diffViewProvider, { + isEditing: false, + relPath: undefined, + editType: undefined, + createdDirs: [], + originalContent, + activeDiffEditor: undefined, + }) + } + + const recoveryDocument = (absolutePath: string): vscode.TextDocument => + makeTextDocument({ + uri: makeUri(absolutePath), + getText: vi.fn().mockReturnValue("streamed, never approved"), + isDirty: true, + }) + + it("does not open the diff once the acquisition has timed out on the path", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const document = recoveryDocument(absolutePath) + const editor = makeTextEditor({ document }) + // Nothing on screen for this path, so no listener resolves the acquisition early. + vi.mocked(vscode.window).visibleTextEditors = [] + const wire = wireAcquisition(editor) + inactiveProvider(undefined) + vi.useFakeTimers() + let failure: unknown + try { + // Kicked off without awaiting: the recovery awaits the acquisition, and the acquisition only + // ever settles on the timer. + const recovery = diffViewProvider.attemptRollbackRecovery(recoveryFor(relPath, "modify")) + // Attached at kickoff: the timeout rejects while the clock below is being advanced, and a + // handler fitted only afterwards would let the runtime log it as an unhandled rejection first. + const rejection = recovery.catch((reason: unknown) => reason) + // The pre-open starts in the same synchronous stretch that installs the edit context. + expect(vscode.window.showTextDocument).toHaveBeenCalledTimes(1) + expect(vscode.commands.executeCommand).not.toHaveBeenCalled() + expect(wire.registered()).toBe(true) + await vi.advanceTimersByTimeAsync(10_000) + failure = await rejection + expect(String(failure)).toContain("Failed to open diff editor for /mock/cwd/mock-recovery-target.ts") + expect(String(failure)).toContain("within 10 seconds") + expect(wire.disposeOpenDocument).toHaveBeenCalledTimes(1) + expect(wire.disposeVisibleEditors).toHaveBeenCalledTimes(1) + expect(diffViewProvider["isEditing"]).toBe(false) + expect(diffViewProvider["relPath"]).toBeUndefined() + expect(diffViewProvider["originalContent"]).toBeUndefined() + // The pre-open the timeout could not cancel settles here, so the follow-up callback runs on a + // path whose acquisition already reported failure to its caller. + wire.settleShow() + expect(wire.showSettled()).toBe(true) + await vi.advanceTimersByTimeAsync(0) + } finally { + vi.useRealTimers() + } + expect(vscode.commands.executeCommand).not.toHaveBeenCalled() + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + }) + + it("builds the diff original side from the baseline the acquisition captured", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const document = recoveryDocument(absolutePath) + const editor = makeTextEditor({ document }) + vi.mocked(vscode.window).visibleTextEditors = [editor] + const wire = wireAcquisition(editor) + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + // The original side carries the compared content base64-encoded in its query, which is the only + // observable trace of the baseline the diff command was handed. The double provides with(), the + // one member the acquisition reads off the parsed uri. + const originalQueries: string[] = [] + vi.mocked(vscode.Uri.parse).mockImplementation((value: string) => { + return { + with: (change: { query?: string }) => { + originalQueries.push(String(change.query)) + // Only forwarded to the doubled diff command; the double carries nothing else. + return { fsPath: value } as unknown as vscode.Uri + }, + } as unknown as vscode.Uri + }) + inactiveProvider("left over from the previous edit") + vi.useFakeTimers() + let listenerRun: unknown + try { + const recovery = diffViewProvider.attemptRollbackRecovery(recoveryFor(relPath, "modify")) + recovery.catch(() => undefined) + // Mid-flight another flow installs a different baseline on the same provider. A callback that + // read the field when it ran would build this diff from that other content instead. + diffViewProvider["originalContent"] = "swapped mid-flight" + wire.settleShow() + // The listener awaits a tick before it looks the editor up, so the tick has to advance. + listenerRun = wire.documentOpened(recoveryDocument(absolutePath)) + await vi.advanceTimersByTimeAsync(1) + await listenerRun + await expect(recovery).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + // The capture is for the diff original side, which is what this row names. What the buffer restore + // is handed is still the live field, so the mid-flight value is what the rollback writes back; that + // half is stated here rather than changed, because widening the guard into the restore would be a + // second decision the row did not ask for. + expect(restorePreStreamBuffer).toHaveBeenCalledWith(absolutePath, editor.document, "swapped mid-flight") + expect(originalQueries).toEqual([Buffer.from("captured at acquisition").toString("base64")]) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "vscode.diff", + expect.anything(), + expect.anything(), + `mock-recovery-target.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + { preserveFocus: true }, + ) + }) + }) }) From 23f42dea1539aa49a26918f7d016fb1c2abf32e9 Mon Sep 17 00:00:00 2001 From: abort-merge Date: Sun, 8 Nov 2026 13:41:09 +0800 Subject: [PATCH 13/19] fix(u5): stop a recovery from rolling back an edit it no longer owns #1930 row 4505100232 (src/integrations/editor/DiffViewProvider.ts:1182 at 22969c635). attemptRollbackRecovery() installs the captured edit, awaits the buffer's re-acquisition, and then runs revertChanges(). The install is not a reservation: while that acquisition is awaited, the retry the refused rollback is waiting for can open a different target on the same provider, and open() overwrites relPath, editType, createdDirs and originalContent. revertChanges() reads this class fields rather than the record it was handed, so a recovery that finished its await unchecked restored and saved the other edit's content into the other edit's target and tore a live edit down - the recovery never stops on its own, it just stops being about the edit it started from. The recovery is now bound to the edit it started from. Ownership - this edit's target still installed as this edit's type - is re-checked when the acquisition settles, and a recovery that lost the provider returns the same false the caller already keeps refusing on, leaves the live edit's state alone rather than rolling it back to what this recovery found, and does not install the editor it managed to acquire into that edit's slot. The baseline is not part of ownership: a later flow replacing originalContent is the diff-side question the acquisition's own capture at :1182 answers, so the guard names target and edit type and nothing else. Two cases in DiffViewProvider.spec.ts: a retry taking the provider for another target mid-acquisition (recovery reports false, nothing is restored or saved for either path, and the live edit keeps its own target, baseline and editor slot), and the provider being cleared again mid-acquisition (recovery reports false and drops the editor it acquired rather than installing it). With the component reverted to 22969c635 those two are the only failures - the first resolves true because the rollback ran on the other edit's state, the second leaves the acquired editor installed - and the other 97 pass; the component was put back byte-for-byte afterwards. Measured on the pinned runtime v22.23.4: 99 passed (99) in the focused spec, 107 passed across integrations/editor, the misc shard 2120 passed / 13 skipped and the core shard 4294 passed / 9 skipped both exiting 0 with an empty FAIL-set, tsc --noEmit -p tsconfig.json clean, eslint --max-warnings=0 clean on both touched files, prettier clean, eslint-suppressions unchanged at 265 files / 2870 entries and that file not dirty. NO local Stryker preflight ran for this commit. --- src/integrations/editor/DiffViewProvider.ts | 33 +++++++- .../editor/__tests__/DiffViewProvider.spec.ts | 79 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 821cb241bf..09a04fca65 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -597,6 +597,12 @@ export class DiffViewProvider { * * A live edit is not hijacked. If an edit is in progress for another target this recovery cannot * run without rolling back somebody else work, so it reports false and the caller keeps refusing. + * + * The same bound holds across the await this recovery performs. It re-acquires the buffer, and while + * that awaits another flow can take the provider for a different edit; ownership is re-checked when + * the acquisition settles, and a recovery that no longer owns the edit it started from stops there + * with the live edit's state untouched, because the rollback below reads this class fields rather + * than the record it was handed. */ async attemptRollbackRecovery(context: RollbackRecoveryContext): Promise { if (this.isEditing) { @@ -629,9 +635,11 @@ export class DiffViewProvider { // If the file is gone by now the acquisition rejects, and the caller treats a rejection exactly // like a refused restore - it keeps the refusal instead of clearing it on a rollback that did not // happen. + let acquiredEditor: vscode.TextEditor try { - this.activeDiffEditor = await this.openDiffEditor() + acquiredEditor = await this.openDiffEditor() } catch (acquisitionError: unknown) { + // Captured above: a failed acquisition puts the provider back exactly as it was found. this.isEditing = wasEditing this.relPath = previousRelPath this.editType = previousEditType @@ -639,6 +647,29 @@ export class DiffViewProvider { this.originalContent = previousOriginalContent throw acquisitionError } + // The acquisition is where this recovery spends its time, and it awaits editor work the rest of the + // extension keeps running through: while this one waits, a retry can start another edit on this + // very provider, and open() overwrites relPath, editType, createdDirs and originalContent. + // revertChanges() works from whatever this class holds the moment it runs, not from the record this + // recovery installed, so finishing here unchecked would restore and save the other edit's content + // into the other edit's target and tear that live edit down. Recovery is bound to the edit that + // started it: once this provider answers to a different edit it stops, reports the same false the + // caller already keeps refusing on, and leaves the live edit's state alone rather than rolling it + // back to something this recovery found. The baseline is not part of ownership - a later flow + // replacing originalContent is the diff-side question the acquisition's own capture answers, not a + // claim on the provider. + if ( + !this.isEditing || + !this.relPath || + !arePathsEqual(path.resolve(this.cwd, this.relPath), context.absolutePath) || + this.editType !== context.editType + ) { + // Somebody else owns this provider now, and its editor is theirs to install: the acquired editor + // is dropped without being written into activeDiffEditor, because a recovery that no longer owns + // the edit must not steer the live one either. + return false + } + this.activeDiffEditor = acquiredEditor return this.revertChanges() } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 27b7f153da..8e1361d1b9 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2606,6 +2606,85 @@ describe("DiffViewProvider", () => { expect(restorePreStreamBuffer).toHaveBeenCalledWith(absolutePath, editor.document, "on-disk original") expect(saveBufferClean).toHaveBeenCalledWith(absolutePath, editor.document) }) + + it("stops when a retry takes the provider for another edit while the buffer is being acquired", async () => { + const relPath = "mock-recovery-target.ts" + const otherRelPath = "another-target.ts" + const editor = simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + const keepOrCloseEditedFile = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = keepOrCloseEditedFile + // The acquisition is the await this recovery performs, and the next attempt does not wait for it: + // a refused rollback reports, the tool keeps the captured context across the reset, and the + // retry opens a different target on this same provider while this recovery is still waiting. + // open() overwrites exactly the fields revertChanges() works from, so a recovery that finished + // here unchecked would restore and save the other edit's content into the other edit's target. + diffViewProvider["openDiffEditor"] = vi.fn(async () => { + Object.assign(diffViewProvider, { + isEditing: true, + relPath: otherRelPath, + editType: "modify", + createdDirs: [], + originalContent: "the other file's own original", + }) + return editor + }) + + // The caller keeps its refusal on a false return, which is the only honest outcome here: + // no rollback of this edit ran. + await expect(diffViewProvider.attemptRollbackRecovery(recoveryContextFor(relPath, "modify"))).resolves.toBe( + false, + ) + + // Nothing was restored and nothing was saved for either path. + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + expect(keepOrCloseEditedFile).not.toHaveBeenCalled() + // The live edit keeps the provider it just took: its own target, its own baseline, and the + // editor slot left for its own flow to fill. + expect(diffViewProvider["isEditing"]).toBe(true) + expect(diffViewProvider["relPath"]).toBe(otherRelPath) + expect(diffViewProvider["editType"]).toBe("modify") + expect(diffViewProvider["originalContent"]).toBe("the other file's own original") + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + }) + + it("stops when the provider is cleared again while its editor is being acquired", async () => { + const relPath = "mock-recovery-target.ts" + const editor = simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + // The other way ownership goes away: something resets the provider out from under the recovery, + // so the install this recovery made is gone by the time the acquisition settles and the rollback + // would read a relPath and a baseline that belong to no edit at all. + diffViewProvider["openDiffEditor"] = vi.fn(async () => { + Object.assign(diffViewProvider, { + isEditing: false, + relPath: undefined, + editType: undefined, + createdDirs: [], + originalContent: undefined, + }) + return editor + }) + + await expect(diffViewProvider.attemptRollbackRecovery(recoveryContextFor(relPath, "modify"))).resolves.toBe( + false, + ) + + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + expect(diffViewProvider["isEditing"]).toBe(false) + // The editor this recovery managed to acquire is dropped rather than installed: a recovery that + // no longer owns the edit does not steer the provider with it either. + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + }) }) // The acquisition inside openDiffEditor() races a ten-second timer against a showTextDocument it cannot // cancel. Both cases below are about what that leaves behind: a diff nobody is waiting for, and a diff From 13b19748e03be800f9719e96ceca23ee828dfca7 Mon Sep 17 00:00:00 2001 From: abort-merge Date: Sun, 8 Nov 2026 14:18:21 +0800 Subject: [PATCH 14/19] fix(editor): ask who owns the provider before the failed acquisition undoes its install 23f42dea1 bound the recovery to its own edit when the acquisition settles, and left the rejection path holding the older rule: the catch restored isEditing, relPath, editType, createdDirs and originalContent unconditionally. A guard that restores on one side of the await and not the other is half a guard. A retry that takes this provider for its own target while the recovery waits, followed by an acquisition that cannot reopen the buffer, ended with this call's snapshot written over the live edit - its relPath, its edit type and its baseline gone, its buffer still open behind a provider now claiming a different install - and the caller's next attempt on the original target ran against that. The catch now asks the same ownership question the settled path asks. The fields go back only when the provider still answers to this install or no edit holds it at all; when isEditing reports an edit on another target the error is rethrown with that edit's state left to its owner. Nothing else changes: a rejection still rethrows, a recovery that still owns the install is still undone exactly as before, and the undo after a rejection is spec:2543's job to pin, which it still does. Verification at this change (node v22.23.4): - focused DiffViewProvider spec: 100 passed (100), the new case driving a retry that installs its own edit inside the acquisition and the acquisition then rejecting; the live edit keeps its target, edit type, baseline and an empty editor slot - spec:2543 (restores the install when the recovery cannot re-acquire the buffer) still green - integrations/editor directory: 2 files, 108 passed (108) - misc shard: 117 files passed | 1 skipped, 2121 passed | 13 skipped - core shard: 198 files, 4294 passed | 9 skipped - negative control: DiffViewProvider.ts restored byte-for-byte from 23f42dea1 with the new case kept - 1 failed (expected false to be true) | 99 passed; written back and compared equal afterwards, no stash used - eslint --max-warnings=0 on both files: clean; eslint-suppressions.json unchanged and not dirty - tsc --noEmit -p src/tsconfig.json: clean; prettier --check: clean - knip --treat-config-hints-as-errors reports the same 4 unused exported enum members and 1 configuration hint at 23f42dea1 as here: unchanged, and nothing this commit exports Answers the review row on PR 1930 about the rejection path needing the same ownership protection the settled path got. --- src/integrations/editor/DiffViewProvider.ts | 31 ++++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 09a04fca65..4fe836188c 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -602,7 +602,9 @@ export class DiffViewProvider { * that awaits another flow can take the provider for a different edit; ownership is re-checked when * the acquisition settles, and a recovery that no longer owns the edit it started from stops there * with the live edit's state untouched, because the rollback below reads this class fields rather - * than the record it was handed. + * than the record it was handed. The question is asked on the rejection path as well: the fields are + * put back only while the provider still answers to this install or no edit holds it at all, so a + * recovery that could not reopen its buffer does not hand the provider over a live edit either. */ async attemptRollbackRecovery(context: RollbackRecoveryContext): Promise { if (this.isEditing) { @@ -639,12 +641,27 @@ export class DiffViewProvider { try { acquiredEditor = await this.openDiffEditor() } catch (acquisitionError: unknown) { - // Captured above: a failed acquisition puts the provider back exactly as it was found. - this.isEditing = wasEditing - this.relPath = previousRelPath - this.editType = previousEditType - this.createdDirs = previousCreatedDirs - this.originalContent = previousOriginalContent + // The ownership question the settled acquisition asks below, asked here too: a rejection does not + // unbind this recovery from the edit that started it, and a provider another edit took while this + // one waited is answerable to that edit, not to this snapshot. Restoring unconditionally wrote + // this call's captured fields over a live edit on a different target - its relPath, its edit type + // and its baseline gone, its buffer left open behind a provider that now claimed otherwise - and + // the recovery that failed to take a buffer had taken something else's edit instead. + const heldForAnotherTarget = + this.isEditing && + (!this.relPath || + !arePathsEqual(path.resolve(this.cwd, this.relPath), context.absolutePath) || + this.editType !== context.editType) + if (!heldForAnotherTarget) { + // Nobody else holds an edit, or this install is still the one the provider answers to: put it + // back exactly as it was found, which is what keeps a later attempt from walking past the + // acquisition into a revertChanges() that finds no document. + this.isEditing = wasEditing + this.relPath = previousRelPath + this.editType = previousEditType + this.createdDirs = previousCreatedDirs + this.originalContent = previousOriginalContent + } throw acquisitionError } // The acquisition is where this recovery spends its time, and it awaits editor work the rest of the diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 8e1361d1b9..808ffd47cf 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2652,6 +2652,51 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() }) + it("leaves a live edit alone when its own acquisition rejects after another edit was taken", async () => { + const relPath = "mock-recovery-target.ts" + const otherRelPath = "another-target.ts" + simulateReset() + const gone = new Error("EPERM: the file is gone before the buffer could be reopened") + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + const keepOrCloseEditedFile = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = keepOrCloseEditedFile + // The interleaving of the case above, with the acquisition rejecting instead of settling: a retry + // takes the provider for its own target while this recovery waits, and then the buffer this + // recovery needs cannot be reopened. The recovery reports its own failure, and the fields it + // captured are not its own to put back any more - restoring them unconditionally handed the + // provider to a stale install while an edit on another target was live on it, with its buffer + // open and its baseline gone. + diffViewProvider["openDiffEditor"] = vi.fn(async () => { + Object.assign(diffViewProvider, { + isEditing: true, + relPath: otherRelPath, + editType: "modify", + createdDirs: [], + originalContent: "the other file's own original", + }) + throw gone + }) + + await expect(diffViewProvider.attemptRollbackRecovery(recoveryContextFor(relPath, "modify"))).rejects.toBe( + gone, + ) + + // No rollback ran for either target. + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + expect(keepOrCloseEditedFile).not.toHaveBeenCalled() + // The live edit keeps the provider it took: its own target, its own edit type, its own baseline, + // and no editor installed behind its back. + expect(diffViewProvider["isEditing"]).toBe(true) + expect(diffViewProvider["relPath"]).toBe(otherRelPath) + expect(diffViewProvider["editType"]).toBe("modify") + expect(diffViewProvider["originalContent"]).toBe("the other file's own original") + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + }) + it("stops when the provider is cleared again while its editor is being acquired", async () => { const relPath = "mock-recovery-target.ts" const editor = simulateReset() From 60f91cdcdda1ff7141a383f5a2a300328c398cfb Mon Sep 17 00:00:00 2001 From: abort-merge Date: Sun, 8 Nov 2026 14:27:40 +0800 Subject: [PATCH 15/19] fix(u5): put the tab the abandoned pre-open left where the caller is not The timeout inside openDiffEditor() gives up on a diff nobody is waiting for, and the row about it stopped at the diff: showTextDocument cannot be cancelled, so the plain tab the pre-open asked for stays on screen when its callback runs on a path whose acquisition has already reported failure. Nothing holds that tab - the disposables went with the timeout, and the provider is back the way the caller found it - so on a refused write it shows a buffer nobody approved in a plain editor. Reconciling it is decided by what this call can prove about the tab, not by what it meant to do with it. The tab list is read before the pre-open, while it still says what it means: a path that was already open when the call started belongs to whoever opened it, and once the listeners are disposed a late callback cannot tell a user's opening during the wait from its own, so only a tab this call can prove it opened is its to close. Of those, only a clean one is closed - a dirty buffer may hold streamed content nobody approved or the user's own typing, and a pre-open snapshot cannot tell those apart. Closing a dirty tab is not a way to discard it either: discardFileTab() restores and saves the buffer before closing, so force-closing an uncertain buffer publishes the content this write never got approved. An uncertain buffer is preserved with its content instead. Verification at this change (node v22.23.4): - three cases in the abandoned-acquisition suite: the pre-open's own tab (nothing open at kickoff, pinned non-preview, clean) is closed; a dirty tab is left alone; a tab that was already open for the path when the call started is left alone - focused DiffViewProvider spec: 103 passed (103); integrations/editor directory: 2 files, 111 passed (111) - negative control with DiffViewProvider.ts restored byte-for-byte from 13b19748e and the new cases kept: 1 failed | 102 passed - the closing case fails, expected [] to deeply equal [tab]. The two preservation cases pass with and without the change: they pin what the reconciliation must not do, so they are guards rather than proofs of it - eslint --max-warnings=0 on both files: clean; prettier --check: clean; no other file touched --- src/integrations/editor/DiffViewProvider.ts | 39 ++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 96 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 4fe836188c..a46d7186b8 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1193,6 +1193,21 @@ export class DiffViewProvider { } } + /** + * The plain-text tab showing this path, if one is open. A diff tab is not one: the acquisition + * below pre-opens the file as a text document, and that is the tab it may have to put away. + */ + private findOpenFileTab(fsPath: string): vscode.Tab | undefined { + return vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .find( + (tab) => + tab.input instanceof vscode.TabInputText && + tab.input.uri.scheme === "file" && + arePathsEqual(tab.input.uri.fsPath, fsPath), + ) + } + private async openDiffEditor(): Promise { if (!this.relPath) { throw new Error( @@ -1231,6 +1246,11 @@ export class DiffViewProvider { // Set when this acquisition gives up. The timeout rejects the promise but cannot cancel // showTextDocument, so without this the diff command still runs for a caller that left. let acquisitionAbandoned = false + // Read before the pre-open, while the tab list still says what it means. A tab that was already + // here when this call started belongs to whoever opened it, and a tab this call opens belongs to + // this call - which is the only difference that survives the wait, because once the listeners are + // gone nothing records who brought a tab onto the screen. + const fileTabWasOpenBeforePreopen = this.findOpenFileTab(uri.fsPath) !== undefined let timeoutId: NodeJS.Timeout | undefined const disposables: vscode.Disposable[] = [] @@ -1300,6 +1320,25 @@ export class DiffViewProvider { // Nobody is waiting for this acquisition any more. The tab would outlive every listener it // needed (the disposables went with the timeout) and the caller has already put the provider // back the way it found it, so the diff would compare against somebody else baseline. + // The pre-open still ran, so the tab it opened is standing here with nobody to take it into + // the diff. Reconciling it is decided by what this call can prove about the tab, not by what + // it hoped to do with it: a tab that was already open when this call started is not this + // call's to close, and neither is one the user opened during the wait - the listeners were + // disposed with the timeout, so this callback cannot tell their opening from its own. Only a + // tab this call can prove it opened qualifies, and the non-preview tab the pre-open asked for + // is the distinguishing mark of it. Of those, only a clean one is closed: a dirty buffer may + // hold the streamed content nobody approved or the user's own typing, and a pre-open snapshot + // cannot tell those two apart, so an uncertain buffer keeps its content rather than being + // put away through the save-on-close path that would publish it. + const orphanedTab = this.findOpenFileTab(uri.fsPath) + if ( + orphanedTab && + !fileTabWasOpenBeforePreopen && + orphanedTab.isPinned && + !orphanedTab.isDirty + ) { + void vscode.window.tabGroups.close(orphanedTab) + } return } // Execute the diff command after ensuring the file is open as text diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 808ffd47cf..646d55abc1 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2845,6 +2845,102 @@ describe("DiffViewProvider", () => { expect(vscode.commands.executeCommand).not.toHaveBeenCalled() expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() }) + // The reconciliation half of an abandoned acquisition: showTextDocument cannot be cancelled, so the + // plain tab the pre-open asked for is still on screen when the follow-up callback runs on a path + // nobody is waiting for any more. What may be done with that tab turns on what this call can prove + // about it, and the three cases below take the three positions a late callback can find itself in. + const wireAbandonedPreopen = (openedTab: vscode.Tab, openAtKickoff = false) => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const editor = makeTextEditor({ document: recoveryDocument(absolutePath) }) + vi.mocked(vscode.window).visibleTextEditors = [] + const wire = wireAcquisition(editor) + // The tab list is a getter on purpose: it has to answer empty at kickoff, when the acquisition + // records who brought the tab here, and answer with the tab once the pre-open settles late. With + // openAtKickoff the tab was here before this call started, which makes it somebody else's tab. + let shown = openAtKickoff + Object.defineProperty(vscode.window.tabGroups, "all", { + get: (): vscode.TabGroup[] => + shown + ? [ + { + isActive: true, + viewColumn: vscode.ViewColumn.One, + activeTab: undefined, + tabs: [openedTab], + }, + ] + : [], + configurable: true, + }) + const closedTabs: vscode.Tab[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + // The close API also accepts whole groups; this suite only ever closes a tab it named. + if ("length" in tab || "tabs" in tab) { + throw new Error("unexpected tab group close") + } + closedTabs.push(tab) + return Promise.resolve(true) + }) + const abandon = async () => { + inactiveProvider(undefined) + vi.useFakeTimers() + try { + const recovery = diffViewProvider.attemptRollbackRecovery(recoveryFor(relPath, "modify")) + const rejection = recovery.catch((reason: unknown) => reason) + await vi.advanceTimersByTimeAsync(10_000) + expect(String(await rejection)).toContain("Failed to open diff editor for") + // The pre-open the timeout could not cancel settles here, which is when the follow-up + // callback looks at the tab list. + shown = true + wire.settleShow() + await vi.advanceTimersByTimeAsync(0) + } finally { + vi.useRealTimers() + } + expect(wire.showSettled()).toBe(true) + } + return { abandon, closedTabs: () => closedTabs.slice() } + } + + it("closes the plain tab a timed-out pre-open left behind when it holds nothing unsaved", async () => { + const openedTab = makeTab(makeTextTab(`${mockCwd}/mock-recovery-target.ts`), "mock-recovery-target.ts", { + isPinned: true, + }) + const { abandon, closedTabs } = wireAbandonedPreopen(openedTab) + await abandon() + // Every mark of the pre-open's own tab is present: nothing was open for this path when the call + // started, the tab is the non-preview one the pre-open asked for, and nothing unsaved is in it. + // Leaving it open shows a plain editor on a path whose edit was refused, which is the outcome + // this row is about. + expect(closedTabs()).toEqual([openedTab]) + }) + + it("leaves a dirty tab alone when the late callback cannot tell whose typing it holds", async () => { + const openedTab = makeTab(makeTextTab(`${mockCwd}/mock-recovery-target.ts`), "mock-recovery-target.ts", { + isPinned: true, + isDirty: true, + }) + const { abandon, closedTabs } = wireAbandonedPreopen(openedTab) + await abandon() + // The buffer may hold streamed content nobody approved or the user's own typing, and a tab list + // read after the fact cannot tell those two apart. Closing a dirty tab takes the path that saves + // before closing, which would publish the unapproved content: an uncertain buffer is preserved + // with its content instead. + expect(closedTabs()).toEqual([]) + }) + + it("leaves a tab that was already open for the path when the acquisition started", async () => { + const openedTab = makeTab(makeTextTab(`${mockCwd}/mock-recovery-target.ts`), "mock-recovery-target.ts", { + isPinned: true, + }) + const { abandon, closedTabs } = wireAbandonedPreopen(openedTab, true) + await abandon() + // The tab belongs to whoever opened it before this call: a plain-text tab does not take the + // activate-existing-diff fast path, so the acquisition still ran, timed out, and left a tab that + // was on screen before it started. Closing a tab the user was looking at is not a cleanup. + expect(closedTabs()).toEqual([]) + }) it("builds the diff original side from the baseline the acquisition captured", async () => { const relPath = "mock-recovery-target.ts" From e10d9d6951fa505dd0d258a70ae5e1e7b7d3c6b5 Mon Sep 17 00:00:00 2001 From: abort-merge Date: Sun, 8 Nov 2026 14:28:31 +0800 Subject: [PATCH 16/19] test(u5): name the interloper's editor and directories instead of an empty slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The takeover cases proved that a recovery which no longer owns the edit does not roll back the other target, and they asserted the shape of the provider afterwards through fields that read as absence: an empty editor slot, an empty directory list. An assertion of absence passes for the wrong reason - it cannot tell a slot the other edit filled from a slot this recovery cleared on its way out, and it cannot tell directories that were never this edit's from directories this edit overwrote with its own. The interloping edit now installs its own editor and its own created directories, and the assertions are identity assertions: the editor in the slot is the other edit's editor object, the directory list is the array the other edit made. The rejection case gets the same treatment, which is where the directories matter most - putting the captured fields back after a rejection is the half that used to write over a live edit's state unconditionally. Verification at this change (node v22.23.4, test-only - no production file changed): - focused DiffViewProvider spec: 103 passed (103); integrations/editor directory: 2 files, 111 passed (111) - control with the production file restored byte-for-byte from 23f42dea1 (rejection-path guard absent): 2 failed | 101 passed - the rejection case and the tab-closing case - control with it restored from 22969c635 (before the settled-path guard): 4 failed | 99 passed - the takeover case reports the recovery returning true over the other edit, the cleared-again case reports an editor installed into a slot that should have stayed empty (expected {…} to be undefined), and the rejection and tab cases fail as above; the file was written back and compared equal after each run, no stash used - eslint --max-warnings=0 on both files: clean; prettier --check: clean --- .../editor/__tests__/DiffViewProvider.spec.ts | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 646d55abc1..b8d27756fb 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2622,13 +2622,24 @@ describe("DiffViewProvider", () => { // retry opens a different target on this same provider while this recovery is still waiting. // open() overwrites exactly the fields revertChanges() works from, so a recovery that finished // here unchecked would restore and save the other edit's content into the other edit's target. + // A distinct editor double, so an assertion can tell whose editor is in the slot rather than only + // that the slot is empty. + const interloperEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(`${mockCwd}/${otherRelPath}`), + getText: vi.fn().mockReturnValue("the other edit's own streamed buffer"), + isDirty: true, + }), + }) + const otherCreatedDirs = [`${mockCwd}/nested`] diffViewProvider["openDiffEditor"] = vi.fn(async () => { Object.assign(diffViewProvider, { isEditing: true, relPath: otherRelPath, editType: "modify", - createdDirs: [], + createdDirs: otherCreatedDirs, originalContent: "the other file's own original", + activeDiffEditor: interloperEditor, }) return editor }) @@ -2643,13 +2654,17 @@ describe("DiffViewProvider", () => { expect(restorePreStreamBuffer).not.toHaveBeenCalled() expect(saveBufferClean).not.toHaveBeenCalled() expect(keepOrCloseEditedFile).not.toHaveBeenCalled() - // The live edit keeps the provider it just took: its own target, its own baseline, and the - // editor slot left for its own flow to fill. + // The live edit keeps the provider it just took, in every respect: its own target, its own edit + // type, its own baseline, the directories its own open() made, and the editor its own flow + // installed. An identity assertion is what makes the last one a claim about the other edit's + // editor rather than about an empty slot: a recovery that steered the provider on its way out + // could clear the slot or fill it with the buffer this recovery had just reopened. expect(diffViewProvider["isEditing"]).toBe(true) expect(diffViewProvider["relPath"]).toBe(otherRelPath) expect(diffViewProvider["editType"]).toBe("modify") expect(diffViewProvider["originalContent"]).toBe("the other file's own original") - expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + expect(diffViewProvider["createdDirs"]).toEqual(otherCreatedDirs) + expect(diffViewProvider["activeDiffEditor"]).toBe(interloperEditor) }) it("leaves a live edit alone when its own acquisition rejects after another edit was taken", async () => { @@ -2669,13 +2684,24 @@ describe("DiffViewProvider", () => { // captured are not its own to put back any more - restoring them unconditionally handed the // provider to a stale install while an edit on another target was live on it, with its buffer // open and its baseline gone. + // The same install as the case above, editor included: the slot holds the other edit's own + // buffer, so an assertion about it says whether a failed recovery cleared somebody else's editor. + const interloperEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(`${mockCwd}/${otherRelPath}`), + getText: vi.fn().mockReturnValue("the other edit's own streamed buffer"), + isDirty: true, + }), + }) + const otherCreatedDirs = [`${mockCwd}/nested`] diffViewProvider["openDiffEditor"] = vi.fn(async () => { Object.assign(diffViewProvider, { isEditing: true, relPath: otherRelPath, editType: "modify", - createdDirs: [], + createdDirs: otherCreatedDirs, originalContent: "the other file's own original", + activeDiffEditor: interloperEditor, }) throw gone }) @@ -2688,13 +2714,17 @@ describe("DiffViewProvider", () => { expect(restorePreStreamBuffer).not.toHaveBeenCalled() expect(saveBufferClean).not.toHaveBeenCalled() expect(keepOrCloseEditedFile).not.toHaveBeenCalled() - // The live edit keeps the provider it took: its own target, its own edit type, its own baseline, - // and no editor installed behind its back. + // The live edit keeps the provider it took in every respect: its own target, its own edit type, + // its own baseline, the directories its own open() made, and the editor its own flow installed. + // The directories are the field a failed acquisition used to put back unconditionally, and the + // editor is the one the recovery never touched at all - both are asserted by identity so the case + // says the other edit's state survived rather than that a slot happens to be filled. expect(diffViewProvider["isEditing"]).toBe(true) expect(diffViewProvider["relPath"]).toBe(otherRelPath) expect(diffViewProvider["editType"]).toBe("modify") expect(diffViewProvider["originalContent"]).toBe("the other file's own original") - expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + expect(diffViewProvider["createdDirs"]).toEqual(otherCreatedDirs) + expect(diffViewProvider["activeDiffEditor"]).toBe(interloperEditor) }) it("stops when the provider is cleared again while its editor is being acquired", async () => { From 6702cc03a9f5f45263f597af264b4b67154b8541 Mon Sep 17 00:00:00 2001 From: abort-merge Date: Sun, 8 Nov 2026 14:41:48 +0800 Subject: [PATCH 17/19] fix(diff-view): give a recovery the edit it was asked to undo, not just its target attemptRollbackRecovery() protected a live edit by comparing the target and the mode: the resolved relPath against the record absolutePath, and editType against the record editType. Those two fields say which file is being edited and in which mode. They do not say which attempt is doing it, and that is the whole hole: a retry that reopens the same file as the same kind of edit installs values identical in both, so a recovery waiting on a refused rollback accepted that retry as its own install, restored its own baseline into the buffer the retry had reopened, saved it, and reported a clean rollback over an edit it was never asked to undo. Same loss the ownership guards were written to prevent, narrower window, same missing information. Every install now stamps a session. captureRollbackRecoveryContext() records the stamp of the install it snapshots, attemptRollbackRecovery() carries the record stamp through its own re-install - the install is that recovery performing the edit the record describes - and all three ownership questions (the live edit, the failed acquisition, the settled acquisition) require the stamp unchanged alongside the target and the mode they already compared. originalContent and createdDirs differ in value between two sessions but neither is an identity: comparing them would confuse same bytes with same session, so they stay out of the ownership question. Reverting the record own session is untouched, and the new same-session case pins it: the live-edit branch still restores and saves exactly as before. Tests: four cases in the recovery suite - a same-file retry live under a newer session is left alone, a same-session live edit still reverts, a same-file reinstall during the acquisition refuses and keeps its own editor, directories and baseline, and a same-file reinstall followed by a rejected acquisition rethrows without writing this recovery own captured fields over it. Control with DiffViewProvider.ts restored byte-for-byte from e10d9d695 and the cases kept: the three newer-session cases fail (expected true to be false, expected true to be false, expected false to be true); the same-session case passes either way, so it is stated as a guard on what the stamp must not refuse rather than as proof of the stamp. Gates: focused spec 107 passed (107); integrations/editor 115 passed (115); misc shard 117 files 2128 passed | 13 skipped; core shard 198 files 4294 passed | 9 skipped; writeToFileTool, writeToFileTool partial-state and stream-cleanup specs 78 passed | 5 skipped; tsc --noEmit -p src/tsconfig.json clean; eslint --max-warnings=0 clean on the four touched files with eslint-suppressions.json unchanged; prettier --check clean. Local Stryker preflight not run. --- ...ssage-write-to-file-stream-cleanup.spec.ts | 2 + .../tools/__tests__/writeToFileTool.spec.ts | 4 + src/integrations/editor/DiffViewProvider.ts | 39 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 178 ++++++++++++++++++ 4 files changed, 220 insertions(+), 3 deletions(-) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts index 07f27df789..e09da4ea2d 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-write-to-file-stream-cleanup.spec.ts @@ -147,6 +147,8 @@ function buildTask(): PresenterTask { relPath: openedRelPath, absolutePath: path.resolve(taskCwd, openedRelPath), editType: diffViewProvider.editType, + // One open() install per live edit in this mirror, which is what the real provider stamps. + editSession: 1, createdDirs: [], } : undefined, diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 710dd14438..4e15002cb4 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -203,6 +203,9 @@ describe("writeToFileTool", () => { relPath: opened, absolutePath: realPathResolve("/", opened), editType: provider.editType ?? "create", + // The double stands for one open() install, so the record it hands back carries that install + // identity: a recovery is answerable to the session the record names, not only to its target. + editSession: 1, createdDirs: [], originalContent: provider.originalContent, } @@ -1308,6 +1311,7 @@ describe("writeToFileTool", () => { relPath, absolutePath: realPathResolve("/", relPath), editType: "create" as const, + editSession: 1, createdDirs: [], originalContent: "", }) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index a46d7186b8..5c260549b7 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -44,6 +44,13 @@ export interface RollbackRecoveryContext { absolutePath: string /** create and modify roll back differently: one unlinks the file it made, the other restores content. */ editType?: "create" | "modify" + /** + * The edit session this record was captured from. A target and an edit type are not an identity: + * a retry that opens the same file as the same kind of edit installs values identical to the ones + * in this record, and only the session says whether the edit in front of the provider is the one + * this rollback was asked to undo. + */ + editSession: number /** Directories this edit made, so a recovery that unlinks the file can remove them too. */ createdDirs: string[] /** The file content before the edit, for the modify branch. */ @@ -107,6 +114,11 @@ export class DiffViewProvider { scrollLine: number | undefined viewColumn: vscode.ViewColumn }> = [] + // Stamped whenever an edit is installed (open(), and the recovery's own re-install). It is the only + // thing that tells two edits on one provider apart: relPath and editType say what is being edited, + // not which attempt is doing it, so a retry that reopens the same file as the same kind of edit + // looks exactly like the install a retained recovery record describes. + private editSession = 0 private taskRef: WeakRef constructor( @@ -117,6 +129,10 @@ export class DiffViewProvider { } async open(relPath: string): Promise { + // A new edit session starts here, even when the target and the edit type are the same as the last + // one: this install's baseline, directories and buffer are its own, and any recovery record captured + // from an earlier install is now answerable to a different attempt. + this.editSession++ this.relPath = relPath const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) @@ -582,6 +598,7 @@ export class DiffViewProvider { relPath: this.relPath, absolutePath: path.resolve(this.cwd, this.relPath), editType: this.editType, + editSession: this.editSession, createdDirs: [...this.createdDirs], originalContent: this.originalContent, } @@ -609,7 +626,15 @@ export class DiffViewProvider { async attemptRollbackRecovery(context: RollbackRecoveryContext): Promise { if (this.isEditing) { const livePath = this.relPath ? path.resolve(this.cwd, this.relPath) : undefined - if (!livePath || !arePathsEqual(livePath, context.absolutePath)) { + if ( + !livePath || + !arePathsEqual(livePath, context.absolutePath) || + this.editSession !== context.editSession + ) { + // Either a different target is live, or the same target is live under a different session - a + // retry that reopened this file and is mid-edit with its own baseline. Reverting here would undo + // that attempt with an earlier attempt's record, which is the same loss the two guards below + // refuse on the other side of the acquisition. return false } return this.revertChanges() @@ -621,8 +646,13 @@ export class DiffViewProvider { const previousEditType = this.editType const previousCreatedDirs = this.createdDirs const previousOriginalContent = this.originalContent + const previousEditSession = this.editSession this.relPath = context.relPath this.editType = context.editType + // The install is this recovery performing the edit the record describes, so it wears that record + // session: the comparison afterwards asks whether this install is still what the provider answers + // to, and only a stamp that a later open() moved tells it when the target and the type stayed put. + this.editSession = context.editSession this.createdDirs = [...context.createdDirs] this.originalContent = context.originalContent this.isEditing = true @@ -651,7 +681,8 @@ export class DiffViewProvider { this.isEditing && (!this.relPath || !arePathsEqual(path.resolve(this.cwd, this.relPath), context.absolutePath) || - this.editType !== context.editType) + this.editType !== context.editType || + this.editSession !== context.editSession) if (!heldForAnotherTarget) { // Nobody else holds an edit, or this install is still the one the provider answers to: put it // back exactly as it was found, which is what keeps a later attempt from walking past the @@ -661,6 +692,7 @@ export class DiffViewProvider { this.editType = previousEditType this.createdDirs = previousCreatedDirs this.originalContent = previousOriginalContent + this.editSession = previousEditSession } throw acquisitionError } @@ -679,7 +711,8 @@ export class DiffViewProvider { !this.isEditing || !this.relPath || !arePathsEqual(path.resolve(this.cwd, this.relPath), context.absolutePath) || - this.editType !== context.editType + this.editType !== context.editType || + this.editSession !== context.editSession ) { // Somebody else owns this provider now, and its editor is theirs to install: the acquired editor // is dropped without being written into activeDiffEditor, because a recovery that no longer owns diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index b8d27756fb..c64127815e 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2469,6 +2469,9 @@ describe("DiffViewProvider", () => { absolutePath: `${mockCwd}/${relPath}`, editType, createdDirs, + // The record describes the install the provider answers to when it is captured, which is what lets a + // recovery tell a retry apart from the attempt it was asked to undo. + editSession: diffViewProvider["editSession"], originalContent: editType === "modify" ? "on-disk original" : "", }) @@ -2638,6 +2641,9 @@ describe("DiffViewProvider", () => { relPath: otherRelPath, editType: "modify", createdDirs: otherCreatedDirs, + // open() stamps a new session on every install, so a retry is a new edit even when its target and + // edit type are the ones already on the provider. + editSession: diffViewProvider["editSession"] + 1, originalContent: "the other file's own original", activeDiffEditor: interloperEditor, }) @@ -2700,6 +2706,9 @@ describe("DiffViewProvider", () => { relPath: otherRelPath, editType: "modify", createdDirs: otherCreatedDirs, + // open() stamps a new session on every install, so a retry is a new edit even when its target and + // edit type are the ones already on the provider. + editSession: diffViewProvider["editSession"] + 1, originalContent: "the other file's own original", activeDiffEditor: interloperEditor, }) @@ -2760,6 +2769,174 @@ describe("DiffViewProvider", () => { // no longer owns the edit does not steer the provider with it either. expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() }) + it("leaves a retry of the same file alone when the record describes an earlier session", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + const keepOrCloseEditedFile = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = keepOrCloseEditedFile + // Captured while the install it describes is still the one the provider answers to. + const record = recoveryContextFor(relPath, "modify") + // The retry the row is about: the same file, reopened as the same kind of edit, with its own + // baseline in its own buffer. Its target and its edit type are identical to the record's, so + // neither of those says whether this rollback belongs to the edit in front of it. + const retryEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(absolutePath), + getText: vi.fn().mockReturnValue("the retry's own streamed buffer"), + isDirty: true, + }), + }) + const retrySession = diffViewProvider["editSession"] + 1 + Object.assign(diffViewProvider, { + isEditing: true, + relPath, + editType: "modify", + editSession: retrySession, + createdDirs: [], + originalContent: "the retry's own baseline", + activeDiffEditor: retryEditor, + }) + + await expect(diffViewProvider.attemptRollbackRecovery(record)).resolves.toBe(false) + + // The caller keeps its refusal, which is the honest outcome: no rollback of the edit this record + // describes ran. The retry keeps its own edit - its buffer was not restored from an earlier + // attempt's baseline, nothing was saved out of it, and its own editor is still installed. + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + expect(keepOrCloseEditedFile).not.toHaveBeenCalled() + expect(diffViewProvider["isEditing"]).toBe(true) + expect(diffViewProvider["relPath"]).toBe(relPath) + expect(diffViewProvider["editSession"]).toBe(retrySession) + expect(diffViewProvider["originalContent"]).toBe("the retry's own baseline") + expect(diffViewProvider["activeDiffEditor"]).toBe(retryEditor) + }) + + it("reverts the live edit when it is the session the record describes", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const editor = simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + // The pin the case above needs: the stamp refuses another attempt, it does not refuse the + // rollback that was asked for. The edit is live under the session this record names. + Object.assign(diffViewProvider, { + isEditing: true, + relPath, + editType: "modify", + createdDirs: [], + originalContent: "on-disk original", + activeDiffEditor: editor, + }) + const record = recoveryContextFor(relPath, "modify") + + await expect(diffViewProvider.attemptRollbackRecovery(record)).resolves.toBe(true) + + expect(restorePreStreamBuffer).toHaveBeenCalledWith(absolutePath, editor.document, "on-disk original") + expect(saveBufferClean).toHaveBeenCalledWith(absolutePath, editor.document) + }) + + it("stops when the same file is reopened under a newer session while its editor is being acquired", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const editor = simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + const record = recoveryContextFor(relPath, "modify") + const retryEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(absolutePath), + getText: vi.fn().mockReturnValue("the retry's own streamed buffer"), + isDirty: true, + }), + }) + const retryCreatedDirs: string[] = [] + const retrySession = diffViewProvider["editSession"] + 1 + // The retry lands during the acquisition, which is where this recovery spends its time. + diffViewProvider["openDiffEditor"] = vi.fn(async () => { + Object.assign(diffViewProvider, { + isEditing: true, + relPath, + editType: "modify", + editSession: retrySession, + createdDirs: retryCreatedDirs, + originalContent: "the retry's own baseline", + activeDiffEditor: retryEditor, + }) + return editor + }) + + await expect(diffViewProvider.attemptRollbackRecovery(record)).resolves.toBe(false) + + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + // The retry keeps its own install: the editor this recovery acquired is dropped rather than + // written into the slot, and the directories and baseline the retry own open() put there are + // untouched. + expect(diffViewProvider["activeDiffEditor"]).toBe(retryEditor) + expect(diffViewProvider["createdDirs"]).toBe(retryCreatedDirs) + expect(diffViewProvider["originalContent"]).toBe("the retry's own baseline") + expect(diffViewProvider["editSession"]).toBe(retrySession) + }) + + it("rethrows a failed acquisition without putting its own install back over a newer one on the same file", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + const gone = new Error("the buffer this recovery needs cannot be reopened") + simulateReset() + const restorePreStreamBuffer = vi.fn().mockResolvedValue(undefined) + const saveBufferClean = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = restorePreStreamBuffer + diffViewProvider["saveBufferClean"] = saveBufferClean + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + const record = recoveryContextFor(relPath, "modify") + const retryEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(absolutePath), + getText: vi.fn().mockReturnValue("the retry's own streamed buffer"), + isDirty: true, + }), + }) + const retryCreatedDirs: string[] = [] + const retrySession = diffViewProvider["editSession"] + 1 + // Same target, same edit type, newer session, and then the buffer cannot be reopened. + diffViewProvider["openDiffEditor"] = vi.fn(async () => { + Object.assign(diffViewProvider, { + isEditing: true, + relPath, + editType: "modify", + editSession: retrySession, + createdDirs: retryCreatedDirs, + originalContent: "the retry's own baseline", + activeDiffEditor: retryEditor, + }) + throw gone + }) + + await expect(diffViewProvider.attemptRollbackRecovery(record)).rejects.toBe(gone) + + // A rejection does not make this recovery the owner of an edit it does not answer to: the fields + // it captured stay its own to put back, and the retry keeps every one of its own. + expect(restorePreStreamBuffer).not.toHaveBeenCalled() + expect(saveBufferClean).not.toHaveBeenCalled() + expect(diffViewProvider["isEditing"]).toBe(true) + expect(diffViewProvider["relPath"]).toBe(relPath) + expect(diffViewProvider["editSession"]).toBe(retrySession) + expect(diffViewProvider["createdDirs"]).toBe(retryCreatedDirs) + expect(diffViewProvider["originalContent"]).toBe("the retry's own baseline") + expect(diffViewProvider["activeDiffEditor"]).toBe(retryEditor) + }) }) // The acquisition inside openDiffEditor() races a ten-second timer against a showTextDocument it cannot // cancel. Both cases below are about what that leaves behind: a diff nobody is waiting for, and a diff @@ -2772,6 +2949,7 @@ describe("DiffViewProvider", () => { absolutePath: `${mockCwd}/${relPath}`, editType, createdDirs: [], + editSession: diffViewProvider["editSession"], originalContent: editType === "modify" ? "captured at acquisition" : "", }) From 8b95a8ebc6ca73175af449bbc37109c60038df0c Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:38:42 +0800 Subject: [PATCH 18/19] guard open() against finishing an install a later open() replaced Row 4505376421 (DiffViewProvider.ts:135). open() stamped its session and then wrote shared state on the far side of four awaits - the pre-edit read, createDirectoriesForFile, the placeholder write, and the diff-editor acquisition - with nothing asking whether a later open() had already taken the provider. A call that fell behind therefore installed its own baseline, its own directories and its own buffer over the edit that had replaced it, and a recovery record captured from the newer install then described a baseline the stale call had overwritten. The guards this file already ships all live in attemptRollbackRecovery(); open() itself had none. open() now holds each awaited result, asks the one question that tells the two calls apart - relPath and editType are identical for a retry that reopens the same file as the same kind of edit, the session stamp is not - and stops before the write that would have clobbered the newer install. The check stops short of unwinding an already-acquired diff editor: from the acquisition onward this install owns a buffer, and unwinding it is the acquisition-side question attemptRollbackRecovery() answers at its settled-acquisition guard. The test parks one open() inside its pre-edit read, completes a second open() on another path, and asserts the stale call refuses while the newer install keeps relPath, originalContent, createdDirs, its buffer and its stamp. Control: with the pre-fix file installed from HEAD the stale call resolves and the case fails - 1 failed | 107 passed - and restoring the fix is byte-equal. Focused suite at this change: 108 passed. Local Stryker preflight before this push: not run. --- src/integrations/editor/DiffViewProvider.ts | 43 +++++++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 49 +++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 5c260549b7..e1a1a29076 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -132,7 +132,7 @@ export class DiffViewProvider { // A new edit session starts here, even when the target and the edit type are the same as the last // one: this install's baseline, directories and buffer are its own, and any recovery record captured // from an earlier install is now answerable to a different attempt. - this.editSession++ + const openSession = ++this.editSession this.relPath = relPath const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) @@ -161,20 +161,24 @@ export class DiffViewProvider { // after editing to see if cline needs to fix anything. this.preDiagnostics = vscode.languages.getDiagnostics() - if (fileExists) { - this.originalContent = await fs.readFile(absolutePath, "utf-8") - } else { - this.originalContent = "" - } + // The read is the first await that comes back with something to install, so it is also the first + // place where a call that fell behind a later open() can overwrite an edit that had already replaced + // it: hold the result, ask, and only then write it into shared state. + const readContent = fileExists ? await fs.readFile(absolutePath, "utf-8") : "" + this.assertNotSuperseded(openSession, "reading the pre-edit contents") + this.originalContent = readContent // For new files, create any necessary directories and keep track of new // directories to delete if the user denies the operation. - this.createdDirs = await createDirectoriesForFile(absolutePath) + const createdDirs = await createDirectoriesForFile(absolutePath) + this.assertNotSuperseded(openSession, "creating the parent directories") + this.createdDirs = createdDirs // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") } + this.assertNotSuperseded(openSession, "writing the placeholder and closing the existing tab") // If the file was already open, close it (must happen after showing the // diff view since if it's the only tab the column will close). @@ -212,6 +216,9 @@ export class DiffViewProvider { // diff evicts them (VS Code reuses the group's single preview slot). this.snapshotPreviewTabs = this.captureUnrelatedPreviewTabs(absolutePath) + // Last chance to back out cheaply: from here this install owns a buffer, and unwinding an acquired + // diff editor is the acquisition-side question attemptRollbackRecovery() already answers. + this.assertNotSuperseded(openSession, "opening the diff editor") this.activeDiffEditor = await this.openDiffEditor() this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor) this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor) @@ -288,6 +295,28 @@ export class DiffViewProvider { }) } + /** + * Ask, on the far side of an await in open(), whether this call is still the install the provider + * answers to. + * + * open() stamps its session first and then spends nearly all of its remaining time in awaits. What + * it writes on the far side of each of those awaits is shared state - the baseline it read, the + * directories it created, the placeholder, the tabs it closed, the buffer it acquires - so a call + * that fell behind a later open() would finish installing itself over an edit that had already + * replaced it, and a recovery record captured from the newer install would then describe a + * baseline this stale call overwrote. relPath and editType cannot tell the two calls apart, + * because a retry reopens the same file as the same kind of edit; the stamp is the only thing + * that can, so the stamp is what is asked about, and a moved stamp stops the call before the write + * that would have clobbered the newer install. + */ + private assertNotSuperseded(openSession: number, stage: string): void { + if (this.editSession !== openSession) { + throw new Error( + `Edit superseded: another edit took this provider during ${stage} (session ${openSession}, now ${this.editSession}).`, + ) + } + } + async update(accumulatedContent: string, isFinal: boolean) { if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) { throw new Error("Required values not set") diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index c64127815e..dcfeed1200 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -398,6 +398,55 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["documentWasPinned"]).toBe(true) expect(diffViewProvider["documentWasOpen"]).toBe(true) }) + it("refuses to finish installing when a later open() takes the provider during its awaits", async () => { + const stalePath = "stale-target.md" + const newerPath = "newer-target.md" + // The stale call parks inside its pre-edit read. Everything open() writes on the far side of that + // await - the baseline, the directories, the placeholder, the tab bookkeeping, the buffer it + // acquires - is shared state, so letting the stale call finish would install it over the edit + // that already replaced it. + let releaseStaleRead: (value: string) => void = () => undefined + const staleRead = new Promise((resolve) => { + releaseStaleRead = resolve + }) + vi.mocked(fs.readFile) + .mockImplementationOnce(() => staleRead) + .mockResolvedValueOnce("the newer edit’s own baseline") + const staleEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(`${mockCwd}/${stalePath}`), + }), + }) + const newerEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(`${mockCwd}/${newerPath}`), + }), + }) + // Stubbed to the editor the real acquisition would hand back for each path: this case is about which + // install the provider answers to, not about the tab plumbing or the acquisition timer. + diffViewProvider["openDiffEditor"] = vi + .fn() + .mockImplementation(async () => (diffViewProvider["relPath"] === newerPath ? newerEditor : staleEditor)) + diffViewProvider.editType = "modify" + + const staleOpen = diffViewProvider.open(stalePath) + await diffViewProvider.open(newerPath) + const newerSession = diffViewProvider["editSession"] + expect(diffViewProvider["activeDiffEditor"]).toBe(newerEditor) + releaseStaleRead("the stale read, delivered after the newer open landed") + + await expect(staleOpen).rejects.toThrow(/superseded/i) + + // The newer install keeps every field the stale one would have written over it on the far side of + // the await it was parked in. + expect(diffViewProvider["relPath"]).toBe(newerPath) + expect(diffViewProvider["editType"]).toBe("modify") + expect(diffViewProvider["isEditing"]).toBe(true) + expect(diffViewProvider["originalContent"]).toBe("the newer edit’s own baseline") + expect(diffViewProvider["createdDirs"]).toEqual([]) + expect(diffViewProvider["activeDiffEditor"]).toBe(newerEditor) + expect(diffViewProvider["editSession"]).toBe(newerSession) + }) }) describe("scrollToFirstDiff method", () => { From b15f32df376d2cbe987878f8f52bf60bb66353f7 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:39:34 +0800 Subject: [PATCH 19/19] allocate edit session numbers monotonically across a recovery Row 4505376427 (DiffViewProvider.ts:655). Allocation and installation were the same number: open() advanced the field and attemptRollbackRecovery() assigned the recovered record's older value into it, with only the acquisition-rejection path putting the previous value back. A settled recovery therefore left the provider holding an earlier attempt's stamp, and the next open() re-issued a number a retained record already held. Path and edit type say nothing about which attempt a record describes - a retry reopens the same file as the same kind of edit - so the settled-acquisition comparison accepted that record against the new install and rolled the newer edit back with the older attempt's baseline while reporting a successful recovery. Allocation now has its own counter that nothing but open() writes: an install wears a stamp and a recovery may wear an older one, and neither puts a number back into circulation. The guards themselves are unchanged - same fields, same comparison, same refusal - only the source of the stamp moved. The test runs two attempts at one file, rolls the first back through its record, opens the file a third time and asserts the new install keeps its edit when the second attempt record is offered back: the recovery refuses, and nothing is restored or saved out of the live edit. Control: with the previous head's file installed the third open re-issues the number - the assertion fails as expected 2 to be greater than 2 - and restoring the fix is byte-equal. Focused suite at this change: 109 passed. Local Stryker preflight before this push: not run. --- src/integrations/editor/DiffViewProvider.ts | 13 +++- .../editor/__tests__/DiffViewProvider.spec.ts | 65 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index e1a1a29076..1fa3732a09 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -118,7 +118,15 @@ export class DiffViewProvider { // thing that tells two edits on one provider apart: relPath and editType say what is being edited, // not which attempt is doing it, so a retry that reopens the same file as the same kind of edit // looks exactly like the install a retained recovery record describes. + // This is the install stamp, not the allocator: a recovery performs an older attempt's edit and so wears + // that attempt number, which can put the number back below one a later open() already handed out. + // Allocation itself lives in sessionAllocator below, which nothing but open() ever writes. private editSession = 0 + // Allocation counter for those stamps: advanced by open() and never assigned by anything else, so a + // stamp is issued once. If the counter rewound along with a recovery's install, the next open() would + // hand out a number a retained record already holds, and that record - same file, same kind of edit - + // would then read as its own install and roll back an edit it has nothing to do with. + private sessionAllocator = 0 private taskRef: WeakRef constructor( @@ -132,7 +140,8 @@ export class DiffViewProvider { // A new edit session starts here, even when the target and the edit type are the same as the last // one: this install's baseline, directories and buffer are its own, and any recovery record captured // from an earlier install is now answerable to a different attempt. - const openSession = ++this.editSession + const openSession = ++this.sessionAllocator + this.editSession = openSession this.relPath = relPath const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) @@ -681,6 +690,8 @@ export class DiffViewProvider { // The install is this recovery performing the edit the record describes, so it wears that record // session: the comparison afterwards asks whether this install is still what the provider answers // to, and only a stamp that a later open() moved tells it when the target and the type stayed put. + // Wearing the number is not the same as putting it back into circulation: sessionAllocator keeps its + // own value, so the next open() issues a stamp no retained record can already hold. this.editSession = context.editSession this.createdDirs = [...context.createdDirs] this.originalContent = context.originalContent diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index dcfeed1200..a469ddc782 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2986,6 +2986,71 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["originalContent"]).toBe("the retry's own baseline") expect(diffViewProvider["activeDiffEditor"]).toBe(retryEditor) }) + it("never re-issues a session number after a recovery rewinds the install", async () => { + const relPath = "mock-recovery-target.ts" + const absolutePath = `${mockCwd}/${relPath}` + // Stubbed the way the rest of this suite stubs it: these cases are about which install a record is + // allowed to roll back, not about the tab plumbing or the acquisition timer. Each attempt gets its + // own editor so an installed buffer can be told from another attempt's. + const acquired: Array<{ editor: ReturnType }> = [] + diffViewProvider["openDiffEditor"] = vi.fn().mockImplementation(async () => { + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(absolutePath), + getText: vi.fn().mockReturnValue("streamed, never approved"), + isDirty: true, + }), + }) + acquired.push({ editor }) + return editor + }) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["keepOrCloseEditedFile"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["saveBufferClean"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider.editType = "modify" + + // Two attempts at the same file, each record captured while its own install was the live one. + await diffViewProvider.open(relPath) + const firstRecord = recoveryContextFor(relPath, "modify") + const firstSession = firstRecord.editSession + await diffViewProvider.reset() + await diffViewProvider.open(relPath) + const secondRecord = recoveryContextFor(relPath, "modify") + const secondSession = secondRecord.editSession + await diffViewProvider.reset() + expect(secondSession).toBe(firstSession + 1) + + // The first attempt rollback runs: it installs the first attempt edit and rewinds the provider to that + // attempt stamp. + await expect(diffViewProvider.attemptRollbackRecovery(firstRecord)).resolves.toBe(true) + expect(diffViewProvider["editSession"]).toBe(firstSession) + await diffViewProvider.reset() + + // A third attempt on the same file. Allocation has to stay ahead of every stamp a retained record + // already holds, otherwise the second record describes this install as well as it describes its own. + await diffViewProvider.open(relPath) + const thirdSession = diffViewProvider["editSession"] + const thirdEditor = acquired[acquired.length - 1].editor + expect(thirdSession).toBeGreaterThan(secondSession) + const thirdRestore = vi.fn().mockResolvedValue(undefined) + const thirdSave = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreStreamBuffer"] = thirdRestore + diffViewProvider["saveBufferClean"] = thirdSave + + await expect(diffViewProvider.attemptRollbackRecovery(secondRecord)).resolves.toBe(false) + + // The second record belongs to an attempt that already finished its rollback question. The retry in + // front of it keeps its own edit: nothing was restored, nothing was saved, and its stamp, target and + // buffer are still what the provider answers to. + expect(thirdRestore).not.toHaveBeenCalled() + expect(thirdSave).not.toHaveBeenCalled() + expect(diffViewProvider["isEditing"]).toBe(true) + expect(diffViewProvider["relPath"]).toBe(relPath) + expect(diffViewProvider["editSession"]).toBe(thirdSession) + expect(diffViewProvider["activeDiffEditor"]).toBe(thirdEditor) + }) }) // The acquisition inside openDiffEditor() races a ten-second timer against a showTextDocument it cannot // cancel. Both cases below are about what that leaves behind: a diff nobody is waiting for, and a diff