From 920815ef438cd99e60bb222366d525bc529ce2c6 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 17:42:34 +0800 Subject: [PATCH 1/8] feat(editor): async post-save diagnostics on chat-diff save path (L1, #1375) (cherry picked from commit 991ab693526f1d9527fe8278a2e06f68b6575508) --- src/core/task/Task.ts | 9 + .../webview/__tests__/ClineProvider.spec.ts | 20 +- src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 124 ++++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 198 +++++++++++++++++- 5 files changed, 312 insertions(+), 41 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4de2b84590..8da6bb62a6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3345,6 +3345,15 @@ export class Task extends EventEmitter implements TaskLike { // could still build tools and call `createMessage()`. this.abort = true + // Stop post-save diagnostics tails that are still waiting on their delay. A + // disposed task cannot receive their say() emit, and without this the timer (and + // the provider + diagnostics snapshot it holds) survives the teardown. + try { + this.diffViewProvider.cancelPostSaveDiagnosticsTails() + } catch (error) { + console.error("Error cancelling post-save diagnostics tails:", error) + } + // Cancel any in-progress HTTP request try { this.cancelCurrentRequest() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 0e4aa4aa18..75c7e67a82 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4079,12 +4079,17 @@ describe("Project MCP Settings", () => { const expectedRooDir = path.join("/test/workspace", ".roo") const expectedMcpPath = path.join(expectedRooDir, "mcp.json") - // Check that fs.mkdir was called with the correct path - expect(mockedFs.mkdir).toHaveBeenCalledWith(expectedRooDir, { recursive: true }) + // The handler must not create .roo itself: a dangling symlink there resolves + // outside the workspace, and creating that parent is exactly what confinement is + // supposed to prevent. safeWriteJson creates it, but only after its scope check. + expect(mockedFs.mkdir).not.toHaveBeenCalledWith(expectedRooDir, { recursive: true }) expect(pathUtils.getWorkspacePath).toHaveBeenCalled() - // Verify file was created with default content - expect(safeWriteJson).toHaveBeenCalledWith(expectedMcpPath, { mcpServers: {} }, { prettyPrint: true }) + // The project-scoped write carries the workspace root as its confinement scope. + expect(safeWriteJson).toHaveBeenCalledWith(expectedMcpPath, { mcpServers: {} }, { + prettyPrint: true, + confineTo: "/test/workspace", + }) // Check that openFile was called expect(openFileSpy).toHaveBeenCalledWith(expectedMcpPath) @@ -4113,10 +4118,9 @@ describe("Project MCP Settings", () => { const pathUtils = await import("../../../utils/path") vi.mocked(pathUtils.getWorkspacePath).mockReturnValue("/test/workspace") - // Mock fs functions to fail - const fs = await import("fs/promises") - const mockedFs = vi.mocked(fs) - mockedFs.mkdir.mockRejectedValue(new Error("Failed to create directory")) + // The handler no longer creates the directory itself, so the failure has to come + // from the confined write. + vi.mocked(safeWriteJson).mockRejectedValueOnce(new Error("Failed to create directory")) // Trigger openProjectMcpSettings await messageHandler({ diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 908159f7ab..6ca6734c78 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1166,7 +1166,7 @@ }, "integrations/editor/__tests__/DiffViewProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 310 + "count": 306 } }, "integrations/editor/__tests__/EditorUtils.spec.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..31a6e017d7 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -46,6 +46,10 @@ export class DiffViewProvider { private streamedLines: string[] = [] private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] private preEditScrollLine: number | undefined + // One controller per post-save diagnostics tail that is still waiting, so task + // disposal can cancel the wait instead of leaving a timer (and this provider and + // the pre-save diagnostics snapshot it closes over) running past the teardown. + private readonly postSaveTails = new Set() // Tracks whether the user activated the target file's editor tab during the // diff session. When the file was not already open before the edit, we only // keep it open afterward if the user explicitly interacted with it. @@ -1151,8 +1155,11 @@ export class DiffViewProvider { }> { const absolutePath = path.resolve(this.cwd, relPath) - // Get diagnostics before editing the file - this.preDiagnostics = vscode.languages.getDiagnostics() + // Get diagnostics before editing the file. Capture the snapshot locally: + // overlapping saveDirectly calls (multi-file edits) must not let a later + // call overwrite this one's baseline before its diagnostics tail runs. + const preDiagnostics = vscode.languages.getDiagnostics() + this.preDiagnostics = preDiagnostics // Write the content directly to the file await createDirectoriesForFile(absolutePath) @@ -1175,23 +1182,83 @@ export class DiffViewProvider { await doc.save() } - // Force a small delay to ensure diagnostics are triggered - await new Promise((resolve) => setTimeout(resolve, 100)) + // The 100 ms diagnostics-settle wait is carried by the + // emitPostSaveDiagnostics tail (inMemoryDocument) instead of here: + // blocking the save path delayed every openFile=false save even when + // diagnostics were disabled or the write delay was 0. } - let newProblemsMessage = "" - + // L1 (A2): resolve without awaiting the LSP diagnostics settle. The + // diagnostics check becomes a fire-and-forget tail that emits any new + // problems via the existing "error" ClineSay type; the returned + // newProblemsMessage is therefore always undefined. if (diagnosticsEnabled) { - // Add configurable delay to allow linters time to process - const safeDelayMs = Math.max(0, writeDelayMs) + // The method's outer try/catch guarantees it never rejects, so the + // fire-and-forget call needs no .catch wrapper. + void this.emitPostSaveDiagnostics(relPath, writeDelayMs, preDiagnostics, !openFile) + } + + // Store the results for formatFileWriteResponse + this.newProblemsMessage = undefined + this.userEdits = undefined + this.relPath = relPath + this.newContent = content + return { + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: content, + } + } + + // L1 (A2): fire-and-forget post-save diagnostics. After the write delay, + // collects new Error-severity problems and emits them via the existing + // "error" ClineSay type (only Error-severity diagnostics reach this branch; + // "error" carries no task-failure semantics in core). Abort-safe: say() + // rejects when the task is aborted, so the whole body sits inside a + // try/catch that degrades to a console.warn — the tail can never reject. + // The wait itself is registered in postSaveTails so Task disposal can cancel + // it: an unregistered delay keeps the timer, this provider and the pre-save + // diagnostics snapshot alive past disposal, and the tail then does stale + // diagnostics work against a task that is already gone. + private async emitPostSaveDiagnostics( + relPath: string, + writeDelayMs: number, + preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][], + inMemoryDocument = false, + ): Promise { + const controller = new AbortController() + this.postSaveTails.add(controller) + try { + // Add configurable delay to allow linters time to process. When the + // document was opened in memory (openFile=false), the tail also + // carries the 100 ms diagnostics-settle wait that used to block + // saveDirectly. The signal is the disposal hook: delay() rejects with + // AbortError once the task is gone, which is the tail's expected end. + const safeDelayMs = Math.max(0, writeDelayMs) + (inMemoryDocument ? 100 : 0) try { - await delay(safeDelayMs) + await delay(safeDelayMs, { signal: controller.signal }) } catch (error) { - console.warn(`Failed to apply write delay: ${error}`) + if (controller.signal.aborted) { + return + } + throw error + } + // A cancellation can also land between the wait resolving and the work + // starting; either way nothing is queried or emitted after it. + if (controller.signal.aborted) { + return } - const postDiagnostics = vscode.languages.getDiagnostics() + // Filter to the saved file: saveDirectly resolves before this tail + // completes, so in a multi-file write sequence (e.g. apply_patch) + // a later file's problems must not be attributed to this relPath. + const savedFilePath = path.resolve(this.cwd, relPath) + // arePathsEqual: case-insensitive on Windows, where a relPath whose + // casing differs from the diagnostic URI is still the same file. + const postDiagnostics = vscode.languages + .getDiagnostics() + .filter(([uri]) => arePathsEqual(uri.fsPath, savedFilePath)) // Get diagnostic settings from state const task = this.taskRef.deref() @@ -1200,27 +1267,36 @@ export class DiffViewProvider { const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50 const newProblems = await diagnosticsToProblemsString( - getNewDiagnostics(this.preDiagnostics, postDiagnostics), + getNewDiagnostics(preDiagnostics, postDiagnostics), [vscode.DiagnosticSeverity.Error], this.cwd, includeDiagnosticMessages, maxDiagnosticMessages, ) - newProblemsMessage = - newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : "" + if (newProblems.length > 0) { + await task?.say("error", `New problems detected after saving file: ${relPath}\n\n${newProblems}`) + } + } catch (error) { + // Abort-safe: never let a post-save diagnostic emit become an + // unhandled rejection (say() rejects when the task is aborted). + console.warn(`Post-save diagnostics emit failed: ${error}`) } + finally { + this.postSaveTails.delete(controller) + } + } - // Store the results for formatFileWriteResponse - this.newProblemsMessage = newProblemsMessage - this.userEdits = undefined - this.relPath = relPath - this.newContent = content - - return { - newProblemsMessage, - userEdits: undefined, - finalContent: content, + /** + * Cancel post-save diagnostics tails that are still waiting. Called when the + * owning task is disposed. Deliberately NOT called from reset(): reset follows a + * successful write, and the tail that write started still has to report the + * problems it is waiting for. + */ + public cancelPostSaveDiagnosticsTails(): void { + for (const controller of this.postSaveTails) { + controller.abort() } + this.postSaveTails.clear() } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 00b3dcaf7a..472f5ce4a8 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -26,7 +26,14 @@ vi.mock("../../../utils/fs", () => ({ // Mock path vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + normalize: vi.fn((p: string) => p), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), + // diagnosticsToProblemsString formats its output header via + // path.relative(cwd, uri.fsPath).toPosix(); the object-with-toPosix shape + // mirrors the repo's own diagnostics.spec.ts mock. + relative: vi.fn((cwd: string, p: string) => ({ toPosix: () => p.replace(`${cwd}/`, "") })), })) // Mock vscode @@ -165,6 +172,8 @@ describe("DiffViewProvider", () => { }), }), }, + // L1: saveDirectly's fire-and-forget diagnostics tail emits via say(). + say: vi.fn().mockResolvedValue(true), } diffViewProvider = new DiffViewProvider(mockCwd, mockTask) @@ -802,12 +811,17 @@ describe("DiffViewProvider", () => { { preview: false, preserveFocus: true }, ) - // Verify diagnostics were checked after delay + // L1: saveDirectly resolves before the fire-and-forget diagnostics + // tail runs; flush one macrotask tick so the mocked delay (and the + // post-write getDiagnostics) have been reached before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Verify the tail applied the configured write delay expect(mockDelay).toHaveBeenCalledWith(2000) expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - // Verify result - expect(result.newProblemsMessage).toBe("") + // Verify result: L1 no longer returns a problems message + expect(result.newProblemsMessage).toBeUndefined() expect(result.userEdits).toBeUndefined() expect(result.finalContent).toBe("new content") }) @@ -838,6 +852,10 @@ describe("DiffViewProvider", () => { expect(mockDelay).not.toHaveBeenCalled() // getDiagnostics is called once for pre-diagnostics, but not for post-diagnostics expect(vscode.languages.getDiagnostics).toHaveBeenCalledTimes(1) + + // L1: no diagnostics tail is launched, so nothing is ever emitted + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockTask.say).not.toHaveBeenCalled() }) it("should handle negative delay values", async () => { @@ -846,6 +864,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, true, -500) + // L1: the tail runs after resolve; flush one macrotask tick first. + await new Promise((resolve) => setTimeout(resolve, 0)) + // Verify delay was called with 0 (safe minimum) expect(mockDelay).toHaveBeenCalledWith(0) }) @@ -853,11 +874,172 @@ describe("DiffViewProvider", () => { it("should store results for formatFileWriteResponse", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 1000) - // Verify internal state was updated - expect((diffViewProvider as any).newProblemsMessage).toBe("") - expect((diffViewProvider as any).userEdits).toBeUndefined() - expect((diffViewProvider as any).relPath).toBe("test.ts") - expect((diffViewProvider as any).newContent).toBe("new content") + // Verify internal state was updated (L1: the problems message is no + // longer stored; it is emitted asynchronously via say("error")) + expect(diffViewProvider["newProblemsMessage"]).toBeUndefined() + expect(diffViewProvider["userEdits"]).toBeUndefined() + expect(diffViewProvider["relPath"]).toBe("test.ts") + expect(diffViewProvider["newContent"]).toBe("new content") + }) + + it("resolves immediately and emits new problems via say('error') after the write delay", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // Pre-write diagnostics are empty; the post-write snapshot (read by + // the fire-and-forget tail) reports one new Error-severity problem. + // vscode.workspace.fs.stat is an unimplemented vi.fn() mock, so + // diagnosticsToProblemsString takes its "(unavailable)" fallback + // branch and still formats the line. + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "boom", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + // L1: saveDirectly resolves before the tail emits anything. + expect(result.newProblemsMessage).toBeUndefined() + expect(mockTask.say).not.toHaveBeenCalled() + + // Flush the fire-and-forget tail (the mocked delay resolves immediately). + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockDelay).toHaveBeenCalledWith(100) + expect(mockTask.say).toHaveBeenCalledTimes(1) + // The existing "error" ClineSay type is used, with the new-problems text. + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("New problems detected after saving file: test.ts"), + ) + expect(mockTask.say.mock.calls[0]?.[1]).toContain("boom") + }) + + it("attributes only the saved file's new problems to the saved file", async () => { + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // Multi-file write sequence: a later file's new error must not be + // attributed to this tail's relPath by the workspace-wide snapshot. + const ownDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "own-problem", + } + const otherDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "other-file-problem", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [ + [makeUri(`${mockCwd}/test.ts`), [ownDiag]], + [makeUri(`${mockCwd}/other.ts`), [otherDiag]], + ] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + // Flush the fire-and-forget tail. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).toHaveBeenCalledTimes(1) + expect(mockTask.say.mock.calls[0]?.[1]).toContain("own-problem") + expect(mockTask.say.mock.calls[0]?.[1]).not.toContain("other-file-problem") + }) + + it("attributes diagnostics to the saved file when the URI casing differs (Windows)", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32") + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // The diagnostic URI uses different casing than the saved relPath: + // on Windows this is still the same file (arePathsEqual). + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "case-mismatch-problem", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/Test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + // Flush the fire-and-forget tail. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).toHaveBeenCalledTimes(1) + expect(mockTask.say.mock.calls[0]?.[1]).toContain("case-mismatch-problem") + platformSpy.mockRestore() + }) + + it("does not block the save on a diagnostics settle delay when diagnostics are disabled", async () => { + // openFile=false used to await a 100 ms settle delay even when + // diagnostics were disabled; that delay now lives in the tail, which + // does not run at all when diagnosticsEnabled is false. + vi.mocked(vscode.languages.getDiagnostics).mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + const result = await diffViewProvider.saveDirectly("test.ts", "new content", false, false) + + expect(result.finalContent).toBe("new content") + expect(mockDelay).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it("carries the in-memory settle delay in the tail for openFile=false saves", async () => { + vi.mocked(vscode.languages.getDiagnostics).mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 100) + + // writeDelayMs (100) + the 100 ms in-memory diagnostics settle, both + // applied by the tail instead of the save path. + expect(mockDelay).toHaveBeenCalledWith(200) + }) + + it("never calls say when there are no new problems", async () => { + vi.mocked(vscode.languages.getDiagnostics).mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 50) + + // Flush the fire-and-forget tail (pre/post snapshots are both empty, + // so diagnosticsToProblemsString returns "" and nothing is emitted). + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it("logs a warning instead of an unhandled rejection when the post-save say() is aborted", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // One new Error-severity problem so the tail reaches say(). + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "boom", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + // The task is aborted while the diagnostics tail is emitting: say() rejects. + mockTask.say.mockRejectedValueOnce(new Error("aborted")) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 0) + // Flush the fire-and-forget tail. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The method's outer catch swallows the rejection with a warning. + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Post-save diagnostics emit failed:")) + + consoleWarnSpy.mockRestore() }) }) From 088e6432c6eff09e7e3610d4a5c8099b44f584fe Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Thu, 8 Oct 2026 23:28:05 +0800 Subject: [PATCH 2/8] fix(editor): re-check cancellation before persisting a post-save diagnostic row The post-save tail awaits the diagnostic settings and then the problem formatting. Both awaits can outlive the task: the emit below calls task.say("error", ...), which PERSISTS an error row, so it must not start once the caller has been disposed. The re-check after the settings read already existed; the one after the formatting call was missing. Test: 'stops a post-save tail that is cancelled while the diagnostic settings are read' - a real pending problem is staged, the settings read resolves AFTER the cancellation, and the tail must not emit. Disclosure: this test pins the observable contract; it is not a per-line negative control - neutering each of the four abort checks in the tail individually still passes, because the emit is also blocked further down the path. Reported as such rather than claimed as a pin. Local: DiffViewProvider.spec + mcpWriteScope.spec + McpHub.spec = 157 passed / 0 failed; tsc --noEmit 0; eslint 0 err / 0 warn on both touched files. (cherry picked from commit 678bce8a82ab9f8b9798e5b9cf0d3386162eb610) --- src/integrations/editor/DiffViewProvider.ts | 7 +++ .../editor/__tests__/DiffViewProvider.spec.ts | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 31a6e017d7..cfe94c8c43 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1274,6 +1274,13 @@ export class DiffViewProvider { maxDiagnosticMessages, ) + // Formatting is awaited too, so a cancellation can land inside it. The emit below + // persists an error row into the task, so it must not start once the caller is gone: + // say() would otherwise be dropped or land in a task the user already left. + if (controller.signal.aborted) { + return + } + if (newProblems.length > 0) { await task?.say("error", `New problems detected after saving file: ${relPath}\n\n${newProblems}`) } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 472f5ce4a8..e64ffec782 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -950,6 +950,57 @@ describe("DiffViewProvider", () => { expect(mockTask.say.mock.calls[0]?.[1]).not.toContain("other-file-problem") }) + it("stops a post-save tail that is cancelled while the diagnostic settings are read", async () => { + const mockDelay = vi.mocked(delay) + // Two delay() calls exist on this path (the save's own settle wait and the tail's), + // so both must resolve: if the tail's wait never resolves the test would pass + // vacuously instead of proving the re-check. + mockDelay.mockImplementationOnce(() => Promise.resolve()).mockImplementationOnce(() => Promise.resolve()) + + // A real problem is pending, so the only thing that can keep it from being + // persisted is the re-check after the awaited settings read. + const ownDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "cancelled-tail-problem", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [ownDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + // The settings read is the last await before the formatting and the emit. Resolving + // it AFTER the cancellation is how a cancellation lands inside it: the await returns + // normally and only the signal shows that the task is gone. + const provider = mockTask.providerRef.deref() + const originalGetState = provider.getState + const slowGetState = vi.fn( + () => + new Promise((resolve) => + setTimeout(() => resolve({ includeDiagnosticMessages: true, maxDiagnosticMessages: 50 }), 20), + ), + ) + provider.getState = slowGetState + + try { + await diffViewProvider.saveDirectly("test.ts", "content", true, true, 100) + // Let the tail pass the settle delay and reach the settings await. + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(mockTask.say).not.toHaveBeenCalled() + + diffViewProvider.cancelPostSaveDiagnosticsTails() + // The pending settings read resolves after the cancellation. + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(slowGetState).toHaveBeenCalledTimes(1) + // The tail had already taken its pre-save baseline and read the post-save + // diagnostics; it stopped at the re-check, so the pending problem is never + // emitted into a task that no longer exists. + expect(vscode.languages.getDiagnostics).toHaveBeenCalledTimes(2) + expect(mockTask.say).not.toHaveBeenCalled() + } finally { + provider.getState = originalGetState + } + }) + it("attributes diagnostics to the saved file when the URI casing differs (Windows)", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32") vi.mocked(vscode.languages.getDiagnostics).mockClear() From caf128cc80d25e7f7ac1d48621ea4534491405ad Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:50:04 +0800 Subject: [PATCH 3/8] test(editor): teach the write-delay assertions about the tail's abort signal The L1 tail passes { signal } to delay() so task disposal can cancel a wait that is still pending. Four existing assertions pinned delay(ms) with one argument and therefore failed once the tail registered its controller. Updated to expect the signal object. Local: DiffViewProvider.spec 79 passed / 0 failed (71 at the base + 8 from this unit); src-level tsc --noEmit 62 = the worktree baseline; eslint 0 err / 0 warn on all four files. --- .../editor/__tests__/DiffViewProvider.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e64ffec782..a65279db09 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -817,7 +817,7 @@ describe("DiffViewProvider", () => { await new Promise((resolve) => setTimeout(resolve, 0)) // Verify the tail applied the configured write delay - expect(mockDelay).toHaveBeenCalledWith(2000) + expect(mockDelay).toHaveBeenCalledWith(2000, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(vscode.languages.getDiagnostics).toHaveBeenCalled() // Verify result: L1 no longer returns a problems message @@ -868,7 +868,7 @@ describe("DiffViewProvider", () => { await new Promise((resolve) => setTimeout(resolve, 0)) // Verify delay was called with 0 (safe minimum) - expect(mockDelay).toHaveBeenCalledWith(0) + expect(mockDelay).toHaveBeenCalledWith(0, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) it("should store results for formatFileWriteResponse", async () => { @@ -909,7 +909,7 @@ describe("DiffViewProvider", () => { // Flush the fire-and-forget tail (the mocked delay resolves immediately). await new Promise((resolve) => setTimeout(resolve, 0)) - expect(mockDelay).toHaveBeenCalledWith(100) + expect(mockDelay).toHaveBeenCalledWith(100, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(mockTask.say).toHaveBeenCalledTimes(1) // The existing "error" ClineSay type is used, with the new-problems text. expect(mockTask.say).toHaveBeenCalledWith( @@ -1051,7 +1051,7 @@ describe("DiffViewProvider", () => { // writeDelayMs (100) + the 100 ms in-memory diagnostics settle, both // applied by the tail instead of the save path. - expect(mockDelay).toHaveBeenCalledWith(200) + expect(mockDelay).toHaveBeenCalledWith(200, expect.objectContaining({ signal: expect.any(AbortSignal) })) }) it("never calls say when there are no new problems", async () => { From 1e104947e14dccb8c61dccec7891537b04819576 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 01:59:51 +0800 Subject: [PATCH 4/8] test(webview): drop the MCP-confinement assertions from this unit Those two hunks came in with the 7996380c0 patch, but they assert the .roo/mcp.json confinement that lives in #1405 (webviewMessageHandler + safeWriteJson confineTo), not in this branch. Without that production change the assertions are false: on Windows platform-unit-test failed at 'expect(mockedFs.mkdir).not.toHaveBeenCalledWith(...)' because this branch's handler still creates .roo itself. Reverted to the base version of the file; the confinement coverage stays where the production change is - #1405. Local: src-level tsc --noEmit 62 = baseline; ClineProvider.spec 'Project MCP Settings' green again. --- .../webview/__tests__/ClineProvider.spec.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 75c7e67a82..0e4aa4aa18 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4079,17 +4079,12 @@ describe("Project MCP Settings", () => { const expectedRooDir = path.join("/test/workspace", ".roo") const expectedMcpPath = path.join(expectedRooDir, "mcp.json") - // The handler must not create .roo itself: a dangling symlink there resolves - // outside the workspace, and creating that parent is exactly what confinement is - // supposed to prevent. safeWriteJson creates it, but only after its scope check. - expect(mockedFs.mkdir).not.toHaveBeenCalledWith(expectedRooDir, { recursive: true }) + // Check that fs.mkdir was called with the correct path + expect(mockedFs.mkdir).toHaveBeenCalledWith(expectedRooDir, { recursive: true }) expect(pathUtils.getWorkspacePath).toHaveBeenCalled() - // The project-scoped write carries the workspace root as its confinement scope. - expect(safeWriteJson).toHaveBeenCalledWith(expectedMcpPath, { mcpServers: {} }, { - prettyPrint: true, - confineTo: "/test/workspace", - }) + // Verify file was created with default content + expect(safeWriteJson).toHaveBeenCalledWith(expectedMcpPath, { mcpServers: {} }, { prettyPrint: true }) // Check that openFile was called expect(openFileSpy).toHaveBeenCalledWith(expectedMcpPath) @@ -4118,9 +4113,10 @@ describe("Project MCP Settings", () => { const pathUtils = await import("../../../utils/path") vi.mocked(pathUtils.getWorkspacePath).mockReturnValue("/test/workspace") - // The handler no longer creates the directory itself, so the failure has to come - // from the confined write. - vi.mocked(safeWriteJson).mockRejectedValueOnce(new Error("Failed to create directory")) + // Mock fs functions to fail + const fs = await import("fs/promises") + const mockedFs = vi.mocked(fs) + mockedFs.mkdir.mockRejectedValue(new Error("Failed to create directory")) // Trigger openProjectMcpSettings await messageHandler({ From dffa6857883f2dc28571a38243398e71ab412e42 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 02:38:02 +0800 Subject: [PATCH 5/8] fix(editor): latch post-save diagnostics tails against a tail started after disposal Lifecycle: cancelPostSaveDiagnosticsTails() only aborted the controllers that existed at that moment. saveDirectly awaits createDirectoriesForFile, writeFile and the document open/show before it starts the tail, so a save that was already in flight could register a NEW tail after Task disposal had cancelled everything - the tail then called say() on a disposed task and kept the provider and diagnostics snapshot it captured alive. cancelPostSaveDiagnosticsTails() now sets a tailsDisposed latch and emitPostSaveDiagnostics returns before registering a controller once it is set. The latch is set before the aborts so no window exists between the two. Tests: DiffViewProvider 'does not start a post-save diagnostics tail once the tails have been cancelled' (cancel first, then saveDirectly with diagnostics enabled -> delay never called, say never called); Task 'cancels post-save diagnostics tails when the task is disposed' and 'continues disposal when cancelling the post-save diagnostics tails throws' (the step after the cancel in the same teardown still runs, and the failure is logged). Negative controls: drop the latch check -> 1 failed; drop the latch assignment -> 1 failed; remove the try/catch in disposeOnce -> 1 failed. Restored -> 80 passed and 2 passed. Local: DiffViewProvider.spec 80 passed / 0 failed; Task.spec tails tests 2 passed; src-level tsc --noEmit 62 = the worktree baseline; eslint 0 err / 0 warn on all four files. --- src/core/task/__tests__/Task.spec.ts | 26 +++++++++++++++++++ src/integrations/editor/DiffViewProvider.ts | 10 +++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 17 ++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index b484d327d2..40dc122d1f 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -4594,6 +4594,32 @@ describe("Cline", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) }) + it("cancels post-save diagnostics tails when the task is disposed", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false }) + const cancelSpy = vi + .spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails") + .mockImplementation(() => {}) + await task.disposeOnce() + expect(cancelSpy).toHaveBeenCalledTimes(1) + }) + + it("continues disposal when cancelling the post-save diagnostics tails throws", async () => { + const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false }) + vi.spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails").mockImplementation(() => { + throw new Error("cancel boom") + }) + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + // The step after the cancel in the same teardown: if the throw escaped, this would + // never run and the HTTP request would keep streaming into a dead task. + const cancelRequestSpy = vi.spyOn(task, "cancelCurrentRequest").mockImplementation(() => {}) + await task.disposeOnce() + expect(cancelRequestSpy).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error cancelling post-save diagnostics tails:"), + expect.any(Error), + ) + }) + it("refuses to send a request when the task is disposed during the bounded metadata wait", async () => { // Disposal alone — no cancel button, no abortTask — must make the // task observe cancellation: disposeOnce sets the abort state diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index cfe94c8c43..7e1ba06dd6 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -50,6 +50,10 @@ export class DiffViewProvider { // disposal can cancel the wait instead of leaving a timer (and this provider and // the pre-save diagnostics snapshot it closes over) running past the teardown. private readonly postSaveTails = new Set() + // Latched once Task disposal has cancelled the tails. A save that was already awaiting + // its file operations can still reach the tail start after that point, and a tail that + // begins after disposal would emit into a dead task and keep its providers alive. + private tailsDisposed = false // Tracks whether the user activated the target file's editor tab during the // diff session. When the file was not already open before the edit, we only // keep it open afterward if the user explicitly interacted with it. @@ -1227,6 +1231,11 @@ export class DiffViewProvider { preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][], inMemoryDocument = false, ): Promise { + if (this.tailsDisposed) { + // Disposal already ran: emitting now would call say() on a disposed task. + return + } + const controller = new AbortController() this.postSaveTails.add(controller) try { @@ -1301,6 +1310,7 @@ export class DiffViewProvider { * problems it is waiting for. */ public cancelPostSaveDiagnosticsTails(): void { + this.tailsDisposed = true for (const controller of this.postSaveTails) { controller.abort() } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index a65279db09..800ef31234 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -919,6 +919,23 @@ describe("DiffViewProvider", () => { expect(mockTask.say.mock.calls[0]?.[1]).toContain("boom") }) + it("does not start a post-save diagnostics tail once the tails have been cancelled", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + vi.mocked(vscode.languages.getDiagnostics).mockClear() + + // A save that was already awaiting its file operations can reach the tail start + // after Task disposal has cancelled the tails. The latch has to refuse it. + diffViewProvider.cancelPostSaveDiagnosticsTails() + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockDelay).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + it("attributes only the saved file's new problems to the saved file", async () => { vi.mocked(vscode.languages.getDiagnostics).mockClear() From a0e8e40e08305923701c841de81b3f424f758224 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Fri, 9 Oct 2026 02:39:21 +0800 Subject: [PATCH 6/8] fix(test): reach the private disposeOnce with bracket notation task.disposeOnce() is private, so the two new tests failed src-level tsc with TS2341. Bracket notation is the convention this repo uses for private members in tests. Local: src-level tsc --noEmit back to 62 = the worktree baseline; tails tests 2 passed. --- src/core/task/__tests__/Task.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 40dc122d1f..1fa1b6d2d7 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -4599,7 +4599,8 @@ describe("Cline", () => { const cancelSpy = vi .spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails") .mockImplementation(() => {}) - await task.disposeOnce() + // disposeOnce is private; bracket notation is the repo's convention for it. + await task["disposeOnce"]() expect(cancelSpy).toHaveBeenCalledTimes(1) }) @@ -4612,7 +4613,8 @@ describe("Cline", () => { // The step after the cancel in the same teardown: if the throw escaped, this would // never run and the HTTP request would keep streaming into a dead task. const cancelRequestSpy = vi.spyOn(task, "cancelCurrentRequest").mockImplementation(() => {}) - await task.disposeOnce() + // disposeOnce is private; bracket notation is the repo's convention for it. + await task["disposeOnce"]() expect(cancelRequestSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Error cancelling post-save diagnostics tails:"), From b192d86e225a853692e7db49a6f921f562c2024d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:02:11 +0800 Subject: [PATCH 7/8] fix(editor): emit post-save diagnostics as a non-interactive say The diagnostics tail runs writeDelayMs after saveDirectly resolved, so by the time it emits, the task has usually moved on and may be sitting in ask() waiting for the user - approval of the next tool, completion_result. say() bumps lastMessageTs unless the message is marked non-interactive, and ask()'s pWaitFor reads a moved lastMessageTs as "superseded": the pending ask throws AskIgnoredError even though the user never answered. An informational message about a problem the user cannot act on yet was therefore able to cancel the decision they were making. The trigger is ordinary - a save that introduces an Error-severity diagnostic, followed by any ask left unanswered past the write delay. The tail now passes isNonInteractive, the same way every other asynchronous say in Task does. The "error" channel and the message text are unchanged, so nothing the webview or the conversation history sees is different; only the lastMessageTs side effect is gone. The existing emit test asserted the call with two arguments, which couples it to the signature; it now checks the channel and the text by position. A new test pins the flag itself, so removing it fails exactly that test. --- src/integrations/editor/DiffViewProvider.ts | 20 +++++++++-- .../editor/__tests__/DiffViewProvider.spec.ts | 33 ++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 7e1ba06dd6..23a1a06b17 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1291,14 +1291,28 @@ export class DiffViewProvider { } if (newProblems.length > 0) { - await task?.say("error", `New problems detected after saving file: ${relPath}\n\n${newProblems}`) + // This tail runs writeDelayMs after saveDirectly resolved, so the task has usually + // moved on and may be sitting in ask() waiting for the user - the next tool's + // approval, completion_result. say() bumps lastMessageTs unless the message is + // non-interactive, and ask()'s pWaitFor reads a moved lastMessageTs as + // "superseded": the pending ask would throw AskIgnoredError even though the user + // never answered. These diagnostics are informational, so they must not be able to + // cancel an ask; the "error" channel and the text stay exactly as they were. + await task?.say( + "error", + `New problems detected after saving file: ${relPath}\n\n${newProblems}`, + undefined /* images */, + undefined /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + ) } } catch (error) { // Abort-safe: never let a post-save diagnostic emit become an // unhandled rejection (say() rejects when the task is aborted). console.warn(`Post-save diagnostics emit failed: ${error}`) - } - finally { + } finally { this.postSaveTails.delete(controller) } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 800ef31234..4f0fd944f2 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -911,14 +911,37 @@ describe("DiffViewProvider", () => { expect(mockDelay).toHaveBeenCalledWith(100, expect.objectContaining({ signal: expect.any(AbortSignal) })) expect(mockTask.say).toHaveBeenCalledTimes(1) - // The existing "error" ClineSay type is used, with the new-problems text. - expect(mockTask.say).toHaveBeenCalledWith( - "error", - expect.stringContaining("New problems detected after saving file: test.ts"), - ) + // The existing "error" ClineSay type is used, with the new-problems text. Asserted + // by position so this test stays about the channel and the text, not the arity. + expect(mockTask.say.mock.calls[0]?.[0]).toBe("error") + expect(mockTask.say.mock.calls[0]?.[1]).toContain("New problems detected after saving file: test.ts") expect(mockTask.say.mock.calls[0]?.[1]).toContain("boom") }) + it("emits the post-save diagnostics as a non-interactive say so a pending ask survives", async () => { + // The tail runs writeDelayMs after saveDirectly resolved, so the task has usually + // moved on and may be waiting in ask() for the user. say() bumps lastMessageTs + // unless the message is non-interactive, and ask()'s pWaitFor treats a moved + // lastMessageTs as "superseded" - the pending ask would throw AskIgnoredError even + // though the user never answered. + const newDiag: vscode.Diagnostic = { + severity: vscode.DiagnosticSeverity.Error, + range: new vscode.Range(0, 0, 0, 1), + message: "boom", + } + const postDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [[makeUri(`${mockCwd}/test.ts`), [newDiag]]] + vi.mocked(vscode.languages.getDiagnostics).mockReturnValueOnce([]).mockReturnValue(postDiagnostics) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 100) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockTask.say).toHaveBeenCalledTimes(1) + expect(mockTask.say.mock.calls[0]?.[6]).toEqual({ isNonInteractive: true }) + // Only the interactivity flag differs: the channel and the text are unchanged. + expect(mockTask.say.mock.calls[0]?.[0]).toBe("error") + expect(mockTask.say.mock.calls[0]?.[1]).toContain("New problems detected after saving file: test.ts") + }) + it("does not start a post-save diagnostics tail once the tails have been cancelled", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() From bc2075c97bee56b8d16dcc3775b3f42d3f347e62 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:21:44 +0800 Subject: [PATCH 8/8] style(editor): wrap the two constructor calls the format job rejects Formatting only: the same two `new Task({...})` calls the previous commit added are wrapped by prettier, and the only other characters that appear are the trailing commas prettier adds with them - with whitespace and those commas removed the file is byte-identical to the previous commit. The compile job (pnpm format:check) runs on the merge tree, where these lines exceed the print width, and was red because of them. --- src/core/task/__tests__/Task.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 1fa1b6d2d7..35fafaea69 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -4595,7 +4595,12 @@ describe("Cline", () => { }) it("cancels post-save diagnostics tails when the task is disposed", async () => { - const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) const cancelSpy = vi .spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails") .mockImplementation(() => {}) @@ -4605,7 +4610,12 @@ describe("Cline", () => { }) it("continues disposal when cancelling the post-save diagnostics tails throws", async () => { - const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) vi.spyOn(task.diffViewProvider, "cancelPostSaveDiagnosticsTails").mockImplementation(() => { throw new Error("cancel boom") })