From e11ef14d469b582310e6d347d088fa3f21862070 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 09:30:22 +0800 Subject: [PATCH 01/46] fix(mcp): preserve concurrent MCP settings during initial creation (fixes #1371) getMcpSettingsFilePath() created the default mcp_settings.json with a check-then-write: fileExistsAtPath() followed by an unconditional fs.writeFile of the empty stub. Two windows racing at startup both saw the file as absent, and the second blind write truncated the first window's config to the 122-byte stub. The stub write now goes through safeWriteJson with a merge callback: the read happens under the advisory lock, and any config already on disk (written by a concurrent process after the existence check) is preserved instead of clobbered. The fast path (file exists -> no write) is unchanged, so no watcher-triggered reloads or write amplification. Test: regression test reproduces the interleaving (existence check sees absent file, locked read sees the concurrent config) and asserts the creation write carries the concurrent config, not the stub. The safeWriteJson spec mock now honors options.merge. --- src/services/mcp/McpHub.ts | 22 +++++--- src/services/mcp/__tests__/McpHub.spec.ts | 62 ++++++++++++++++++++--- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 1374e430fe..5d6c31a3e5 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -506,13 +506,23 @@ export class McpHub { ) const fileExists = await fileExistsAtPath(mcpSettingsFilePath) if (!fileExists) { - await fs.writeFile( + // Create the default settings file under the advisory lock. The merge + // callback preserves any config a concurrent process wrote between the + // existence check above and the locked read, instead of blindly + // truncating it (see #1371). + await safeWriteJson( mcpSettingsFilePath, - `{ - "mcpServers": { - - } -}`, + { mcpServers: {} }, + { + prettyPrint: true, + merge: (existing) => { + const parsed = existing as { mcpServers?: unknown } | null + if (parsed && parsed.mcpServers && typeof parsed.mcpServers === "object") { + return existing + } + return { mcpServers: {} } + }, + }, ) } return mcpSettingsFilePath diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 96589d8dd6..cab1501b53 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1,4 +1,5 @@ import * as fs from "fs/promises" +import * as path from "path" import type { Mock } from "vitest" import type { ExtensionContext, Uri } from "vscode" @@ -34,12 +35,32 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" // Mock safeWriteJson vi.mock("../../../utils/safeWriteJson", () => ({ - safeWriteJson: vi.fn(async (filePath, data) => { - // Instead of trying to write to the file system, just call fs.writeFile mock - // This avoids the complex file locking and temp file operations - const fs = await import("fs/promises") - return fs.writeFile(filePath, JSON.stringify(data), "utf8") - }), + safeWriteJson: vi.fn( + async ( + filePath: string, + data: unknown, + options?: { merge?: (existing: unknown, incoming: unknown) => unknown }, + ) => { + // Instead of trying to write to the file system, just call fs.writeFile mock + // This avoids the complex file locking and temp file operations. + // When a merge callback is provided, honor it: read the current on-disk + // content via the fs.readFile mock (simulating the read under the lock) + // and let the callback decide the final value. + let value = data + if (options?.merge) { + let existing: unknown = null + try { + const fs = await import("fs/promises") + existing = JSON.parse(await fs.readFile(filePath, "utf8")) + } catch { + existing = null + } + value = options.merge(existing, data) + } + const fs = await import("fs/promises") + return fs.writeFile(filePath, JSON.stringify(value), "utf8") + }, + ), })) vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined) })) @@ -212,6 +233,35 @@ describe("McpHub", () => { watchSpy.mockRestore() }) + describe("getMcpSettingsFilePath", () => { + it("preserves a config written by a concurrent process during initial creation (#1371)", async () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + const concurrentConfig = { + mcpServers: { + "concurrent-server": { type: "stdio", command: "node", args: ["server.js"] }, + }, + } + + // Window A's existence check sees the settings file as absent... + // (One-shot overrides: the factory defaults apply to all other tests.) + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + // ...but by the time the locked read runs (safeWriteJson merge), + // window B's config is already on disk. + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(concurrentConfig)) + + const returnedPath = await mcpHub.getMcpSettingsFilePath() + + expect(returnedPath).toBe(settingsPath) + // The creation write must carry the concurrent config, not the empty stub. + expect(fs.writeFile).toHaveBeenCalledTimes(1) + const [writtenPath, writtenData] = vi.mocked(fs.writeFile).mock.calls[0] + expect(writtenPath).toBe(settingsPath) + expect(JSON.parse(writtenData as string)).toEqual(concurrentConfig) + }) + }) + describe("Discriminated union type handling", () => { it("should create connected connections with proper type", async () => { // Mock StdioClientTransport From 4ab3bf409ace2dfba3bc3ed56be06b759e1858f8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 09:33:23 +0800 Subject: [PATCH 02/46] perf(write-path): remove artificial write delays by default (part of #1375) Two latency sources on the agent file-write path were removed or defaulted off: - DEFAULT_WRITE_DELAY_MS is now 0 instead of 1000, so writes no longer wait a full second for post-save diagnostics by default. The setting itself is unchanged: users who rely on auto-formatters that settle asynchronously (e.g. goimports for Go) can raise writeDelayMs back up; the comment on the constant documents that tradeoff. - WriteToFileTool no longer waits delay(300) before scrollToFirstDiff(). The other five write tools (EditFile, Edit, SearchReplace, ApplyPatch, ApplyDiff) already call scrollToFirstDiff() directly, and DiffViewProvider already re-reveals the first diff on a deferred 100ms timer to beat the diff editor's late layout pass, so the 300ms pause was redundant pacing. The delay() import is removed (DiffViewProvider still uses the package). Tests: ClineProvider spec now asserts the default via DEFAULT_WRITE_DELAY_MS instead of a hardcoded 1000. WriteToFileTool and ClineProvider suites pass (19 + 151). --- packages/types/src/global-settings.ts | 8 +++++--- src/core/tools/WriteToFileTool.ts | 2 -- src/core/webview/__tests__/ClineProvider.spec.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..8793883576 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -17,10 +17,12 @@ import { languagesSchema } from "./vscode.js" /** * Default delay in milliseconds after writes to allow diagnostics to detect potential problems. - * This delay is particularly important for Go and other languages where tools like goimports - * need time to automatically clean up unused imports. + * Defaults to 0: the write path adds no artificial pacing by default, and post-save + * diagnostics are reported after the (zero) delay. Users who rely on auto-formatters that + * settle asynchronously (e.g. goimports for Go) can raise this setting to give formatters + * time to settle before diagnostics are captured. */ -export const DEFAULT_WRITE_DELAY_MS = 1000 +export const DEFAULT_WRITE_DELAY_MS = 0 /** * Default values for the "auto-close files Zoo opened" settings. diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..0c5c80abb9 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -1,5 +1,4 @@ import path from "path" -import delay from "delay" import fs from "fs/promises" import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" @@ -146,7 +145,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { true, ) - await delay(300) task.diffViewProvider.scrollToFirstDiff() let unified = fileExists diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 731124cccc..a064184b6c 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1409,14 +1409,14 @@ describe("ClineProvider", () => { expect(state.language).toBe("pt-BR") }) - test("writeDelayMs defaults to 1000ms", async () => { + test("writeDelayMs defaults to DEFAULT_WRITE_DELAY_MS", async () => { // Mock globalState.get to return undefined for writeDelayMs ;(mockContext.globalState.get as any).mockImplementation((key: string) => { return key === "writeDelayMs" ? undefined : null }) const state = await provider.getState() - expect(state.writeDelayMs).toBe(1000) + expect(state.writeDelayMs).toBe(DEFAULT_WRITE_DELAY_MS) }) test("getState applies fallback defaults for write, diff, and terminal settings", async () => { From 352df167431c4b2144df23c975fc82e4092dc7c2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 09:37:50 +0800 Subject: [PATCH 03/46] fix(task): guard saveClineMessages against abandoned tasks (fixes #1021) Fire-and-forget saveClineMessages() calls could execute updateTaskHistory() after abandonSubtask's atomicUpdatePair() had already cleared parentTaskId/rootTaskId, silently reattaching the severed parent-child link. Check this.abandoned before updateTaskHistory() to catch both the explicit abort save and any in-flight fire-and-forget saves. Per-task message persistence is unaffected: saveTaskMessages still runs, only the (stale) history-item update is skipped. This is the minimal upstream-main form of the fix developed on the local-usage-stats branch (commit 1d1eb915e); that commit's surrounding usage-stats changes are not part of main and are excluded. Regression test in Task.spec.ts: an abandoned task's saveClineMessages() persists messages but never calls updateTaskHistory(). --- src/core/task/Task.ts | 9 ++++ src/core/task/__tests__/Task.spec.ts | 74 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..1a3ede5294 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1130,6 +1130,15 @@ export class Task extends EventEmitter implements TaskLike { // - Final state is emitted when updates stop (trailing: true) this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) + // Guard: don't update the history item for abandoned tasks. Fire-and-forget + // saveClineMessages() calls can reach updateTaskHistory() after + // abandonSubtask's atomicUpdatePair() has already cleared + // parentTaskId/rootTaskId; writing this live Task's stale values would + // silently reattach the severed parent-child link. + if (this.abandoned) { + return false + } + const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 37e228f887..eba160dd7a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -13,6 +13,7 @@ import { type GlobalState, type ProviderSettings, type ModelInfo, + type HistoryItem, type TaskLike, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -27,6 +28,9 @@ import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" +import * as taskMetadataModule from "../../task-persistence/taskMetadata" +import * as taskMessagesModule from "../../task-persistence/taskMessages" +import { getApiMetrics } from "../../../shared/getApiMetrics" type TaskTestAccess = { getSystemPrompt: () => Promise @@ -4297,3 +4301,73 @@ describe("pushToolResultToUserContent", () => { expect(task.userMessageContent[2]).toEqual(toolResult) }) }) + +describe("saveClineMessages abandoned guard (#1021)", () => { + beforeEach(() => { + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + }) + + it("persists messages but does not update task history when the task was abandoned", async () => { + // The history item carries the stale link: a fire-and-forget save that + // reaches updateTaskHistory() after abandonSubtask's atomicUpdatePair() + // cleared parentTaskId/rootTaskId would silently reattach the severed + // parent-child link. + const staleHistoryItem: HistoryItem = { + id: "orphan-subtask", + number: 7, + ts: Date.now(), + task: "orphan subtask", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + rootTaskId: "stale-root", + parentTaskId: "stale-parent", + } + const saveSpy = vi.spyOn(taskMessagesModule, "saveTaskMessages").mockResolvedValue(undefined) + const metaSpy = vi + .spyOn(taskMetadataModule, "taskMetadata") + .mockResolvedValue({ historyItem: staleHistoryItem, tokenUsage: getApiMetrics([]) }) + + try { + const mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/storage" }, + globalState: { + get: vi.fn().mockImplementation(() => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + }, + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" }, + mcpEnabled: false, + }), + getMcpHub: vi.fn().mockReturnValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } as unknown as MockedClineProvider + + const task = new Task({ + provider: mockProvider, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key" }, + task: "orphan subtask", + startTask: false, + }) + + // abandonSubtask severs the link, then aborts the subtask with + // isAbandoned=true; an in-flight fire-and-forget save lands here. + task.abandoned = true + + const saved = await getTaskTestAccess(task).saveClineMessages() + + expect(saved).toBe(false) + expect(saveSpy).toHaveBeenCalledTimes(1) // messages are still persisted + expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() // history link is not reattached + } finally { + saveSpy.mockRestore() + metaSpy.mockRestore() + } + }) +}) From 3f8db83ac2490eaf4c7e7ba55040cdd32f88641e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 09:49:27 +0800 Subject: [PATCH 04/46] test(webview): cover writeDelayMs in getStateToPostToWebview Per CodeRabbit review on this PR: the spec covered the writeDelayMs default and pass-through via getState(), plus the save handler, but not the value returned by getStateToPostToWebview(). Add both cases (persisted value passes through; unset value falls back to DEFAULT_WRITE_DELAY_MS) so a regression that drops the field from the posted state is caught. --- .../webview/__tests__/ClineProvider.spec.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a064184b6c..a8b934cb0f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1419,6 +1419,28 @@ describe("ClineProvider", () => { expect(state.writeDelayMs).toBe(DEFAULT_WRITE_DELAY_MS) }) + test("getStateToPostToWebview returns the persisted writeDelayMs value", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Simulate the updateSettings handler storing the value. + await provider.contextProxy.setValue("writeDelayMs", 500) + + const state = await provider.getStateToPostToWebview() + + expect(state.writeDelayMs).toBe(500) + }) + + test("getStateToPostToWebview defaults writeDelayMs to DEFAULT_WRITE_DELAY_MS when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Ensure the setting is not persisted. + await provider.contextProxy.setValue("writeDelayMs", undefined) + + const state = await provider.getStateToPostToWebview() + + expect(state.writeDelayMs).toBe(DEFAULT_WRITE_DELAY_MS) + }) + test("getState applies fallback defaults for write, diff, and terminal settings", async () => { ;(mockContext.globalState.get as any).mockImplementation((key: string) => { if ( From ce34d4804c008daf4cc42841f6b2d87a18a0a6a4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 09:50:44 +0800 Subject: [PATCH 05/46] test(task): document double assertion in abandoned-guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit review: document why the provider test double uses the as unknown as MockedClineProvider double assertion (Task receives a full ClineProvider at runtime; this focused unit test only exercises a few methods) — same pattern and rationale as the existing Subtask Rate Limiting block. --- src/core/task/__tests__/Task.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index eba160dd7a..b9bb5f2ece 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -4347,6 +4347,9 @@ describe("saveClineMessages abandoned guard (#1021)", () => { getMcpHub: vi.fn().mockReturnValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), + // Task receives a full ClineProvider at runtime; this focused unit test only + // exercises these methods, so the partial double is cast (same pattern as the + // "Subtask Rate Limiting" block above). } as unknown as MockedClineProvider const task = new Task({ From 332253af77c516a4d9ebc6ac36eecc10d0126d99 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 09:58:43 +0800 Subject: [PATCH 06/46] test(mcp): cover getMcpSettingsFilePath fallback branches Codecov reported 2 patch lines (1 missing, 1 partial) in the safeWriteJson merge callback. Add the three remaining fallback cases: absent file (merge sees null), existing content without an mcpServers object, and mcpServers present but not an object - all must write the default stub. All changed lines and branches of the merge callback are now covered. --- src/services/mcp/__tests__/McpHub.spec.ts | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index cab1501b53..1ff277a761 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -260,6 +260,58 @@ describe("McpHub", () => { expect(writtenPath).toBe(settingsPath) expect(JSON.parse(writtenData as string)).toEqual(concurrentConfig) }) + + it("writes the default stub when no settings file exists yet", async () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + + // Existence check and the locked read both see an absent file. + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + vi.mocked(fs.readFile).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + + const returnedPath = await mcpHub.getMcpSettingsFilePath() + + expect(returnedPath).toBe(settingsPath) + expect(fs.writeFile).toHaveBeenCalledTimes(1) + const [writtenPath, writtenData] = vi.mocked(fs.writeFile).mock.calls[0] + expect(writtenPath).toBe(settingsPath) + expect(JSON.parse(writtenData as string)).toEqual({ mcpServers: {} }) + }) + + it("writes the default stub when the existing content has no mcpServers object", async () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + + // Existence check sees an absent file, but the locked read finds content + // that does not carry a mcpServers object (e.g. a torn or foreign write). + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ someOtherKey: true })) + + await mcpHub.getMcpSettingsFilePath() + + expect(fs.writeFile).toHaveBeenCalledTimes(1) + const [, writtenData] = vi.mocked(fs.writeFile).mock.calls[0] + expect(JSON.parse(writtenData as string)).toEqual({ mcpServers: {} }) + }) + + it("writes the default stub when the existing mcpServers value is not an object", async () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: "corrupted" })) + + await mcpHub.getMcpSettingsFilePath() + + expect(fs.writeFile).toHaveBeenCalledTimes(1) + const [, writtenData] = vi.mocked(fs.writeFile).mock.calls[0] + expect(JSON.parse(writtenData as string)).toEqual({ mcpServers: {} }) + }) }) describe("Discriminated union type handling", () => { From 44b6791a6e3dcea19444cb54d57a235f02de5890 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 10:11:06 +0800 Subject: [PATCH 07/46] test(dv): assert default saveChanges delay via DEFAULT_WRITE_DELAY_MS CI (platform-unit-test) caught a hardcoded expectation of delay(1000) in the saveChanges no-arguments test: with the new default the no-parameter saveChanges() passes DEFAULT_WRITE_DELAY_MS (0) through to delay(). Assert the constant instead of the old literal so the test tracks the shared default. --- src/integrations/editor/__tests__/DiffViewProvider.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..679ebc8f3e 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -2,6 +2,7 @@ import { DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from import * as vscode from "vscode" import * as path from "path" import delay from "delay" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" @@ -918,8 +919,8 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveChanges() - // Verify default behavior (enabled=true, delay=2000ms) - expect(mockDelay).toHaveBeenCalledWith(1000) + // Verify default behavior (enabled=true, delay falls back to DEFAULT_WRITE_DELAY_MS) + expect(mockDelay).toHaveBeenCalledWith(DEFAULT_WRITE_DELAY_MS) expect(vscode.languages.getDiagnostics).toHaveBeenCalled() expect(result.newProblemsMessage).toBe("") }) From 131d18d9fcf4d2624867f6a57ed00e0c32860be8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 10:40:53 +0800 Subject: [PATCH 08/46] feat(file-safety): add version token for the guarded-write path (A1, #1375) Introduces the version token - dev:ino:size:mtimeNs:ctimeNs derived from a single fs.stat - a pure function of a file's on-disk state that every process computing from the same state agrees on. The compare-and-swap write guard (A2/A3) will compare the token observed at read time against the token recomputed before a write to detect stale or replaced files. No production callers yet: this is infrastructure for the file-write safety series (plan: easonLiangWorldedtech/Zoo-Code#33), part of upstream epic #1375. --- src/utils/__tests__/versionToken.spec.ts | 103 +++++++++++++++++++++++ src/utils/versionToken.ts | 57 +++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/utils/__tests__/versionToken.spec.ts create mode 100644 src/utils/versionToken.ts diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts new file mode 100644 index 0000000000..56ce269244 --- /dev/null +++ b/src/utils/__tests__/versionToken.spec.ts @@ -0,0 +1,103 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { Stats } from "fs" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { computeVersionToken, versionTokenOfStat } from "../versionToken" + +// Stats is a class-backed interface without a public constructor, so a plain-object +// test double is the only practical way to pin the token format without real files. +// Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): Stats { + const base: Partial = { + dev: 7, + ino: 4242, + size: 1234, + atimeMs: 1_700_000_000_000, + mtimeMs: 1_700_000_000_123.456, + ctimeMs: 1_700_000_000_789.999, + birthtimeMs: 1_700_000_000_000, + } + return { ...base, ...overrides } as unknown as Stats +} + +describe("versionTokenOfStat (A1, epic #1375)", () => { + it("is deterministic for an identical stat", () => { + expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) + }) + + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { + const expected = [ + "7", + "4242", + "1234", + Math.round(1_700_000_000_123.456 * 1e6).toString(), + Math.round(1_700_000_000_789.999 * 1e6).toString(), + ].join(":") + expect(versionTokenOfStat(makeStats())).toBe(expected) + }) + + it("distinguishes size changes at identical timestamps", () => { + expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes mtime changes at identical size", () => { + expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes a replaced file (dev/ino change) with identical content state", () => { + const replaced = makeStats({ dev: 8, ino: 999 }) + expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("preserves sub-ms mtime resolution in the ns field", () => { + const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) + const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) + expect(halfMsLater).not.toBe(wholeMs) + // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at + // this epoch), so allow a bounded drift instead of asserting an exact value. + const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) + expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + }) + + it("handles sizes beyond 32 bits without precision loss", () => { + const size = 5_000_000_000 // > 2^32 + const token = versionTokenOfStat(makeStats({ size })) + expect(token).toContain(`:4242:${size}:`) + }) +}) + +describe("computeVersionToken (A1, epic #1375)", () => { + let tmpDir: string + let file: string + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "version-token-")) + file = path.join(tmpDir, "seed.txt") + await fs.writeFile(file, "seed content", "utf8") + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("derives the token from the on-disk state (single stat)", async () => { + const token = await computeVersionToken(file) + expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + }) + + it("changes when the file content changes", async () => { + const before = await computeVersionToken(file) + // Different size + a new mtime — both must move the token. + await fs.writeFile(file, "seed content, extended", "utf8") + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(await computeVersionToken(file)).not.toBe(before) + }) + + it("rejects with ENOENT for an absent file", async () => { + await expect(computeVersionToken(path.join(tmpDir, "absent.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }) + }) +}) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts new file mode 100644 index 0000000000..ee8e9e82f1 --- /dev/null +++ b/src/utils/versionToken.ts @@ -0,0 +1,57 @@ +import { stat } from "fs/promises" +import type { Stats } from "fs" + +/** + * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). + * + * A token is a pure function of a file's on-disk state, derived from a single + * `fs.stat`, so every process that observes the same file state (a second VS Code + * window, the CLI, the user's own editor tooling) computes the same token. The + * downstream guard phases (A2/A3) compare the token observed at read time with the + * token recomputed just before a write to detect "the file changed since the read" + * (stale) or "the file was replaced by a different file" (dev/ino change). + * + * Format: `dev:ino:size:mtimeNs:ctimeNs` + * + * Resolution note: Node exposes modification/change times as float milliseconds, + * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double + * conversion is correctly rounded, so the derivation is deterministic across + * processes, but it is quantized by double precision (~256 ns at the current epoch). + * Two file states whose timestamps differ by less than the quantum derive the same + * ns field; in practice distinct states differ by at least the OS clock resolution + * (and no write workload produces mtimes closer than that), so the guard contract + * holds: same disk state → same token; changed state → a different token in all + * realistic cases. dev, ino and size are exact integers, so any size or file + * identity change is always detected regardless of the timestamp quantum. + */ + +/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ +function nsFromMs(ms: number): string { + return Math.round(ms * 1e6).toString() +} + +/** + * Build the version token from an already-fetched `Stats` — no I/O. + * + * Exported separately from {@link computeVersionToken} so tests can pin the exact + * format against synthetic stats. + */ +export function versionTokenOfStat(stats: Stats): string { + return [ + stats.dev.toString(), + stats.ino.toString(), + stats.size.toString(), + nsFromMs(stats.mtimeMs), + nsFromMs(stats.ctimeMs), + ].join(":") +} + +/** + * Compute the version token for a file (one `fs.stat`). + * + * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; + * how an unobservable target is treated is decided by the guard layer (A3). + */ +export async function computeVersionToken(filePath: string): Promise { + return versionTokenOfStat(await stat(filePath)) +} From 13188d2e1e4b0f090b8c7faa76ee775c9157120b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:03:05 +0800 Subject: [PATCH 09/46] docs(file-safety): correct ino precision bounds in version token (A1, #1375) Review finding: 'ino is an exact integer' was overstated. Node exposes ino as a float64 number: exact for small POSIX inode numbers, but on modern Windows the file ID exceeds 2^53 so Node's own value is already rounded (verified on node v25: non-zero ino, isSafeInteger=false). It remains deterministic per file (same file -> same token), so the token contract is unchanged; change detection rests on exact dev/size plus the mtime/ctime ns fields. Document the bound instead of claiming exactness. --- src/utils/versionToken.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index ee8e9e82f1..59a8b348fe 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -21,8 +21,15 @@ import type { Stats } from "fs" * ns field; in practice distinct states differ by at least the OS clock resolution * (and no write workload produces mtimes closer than that), so the guard contract * holds: same disk state → same token; changed state → a different token in all - * realistic cases. dev, ino and size are exact integers, so any size or file - * identity change is always detected regardless of the timestamp quantum. + * realistic cases. `dev` and `size` are exact integers. `ino` is Node's + * `number` (float64): exact for small POSIX inode numbers, but on modern Windows + * the underlying file ID exceeds 2^53, so Node's own value is already rounded — + * still deterministic per file (same file → same token), but not guaranteed + * injective across distinct files. Change detection therefore rests on size + + * mtime/ctime: any size change is always detected regardless of the timestamp + * quantum, and a replacement whose size and timestamps are indistinguishable is + * undetectable by any scheme reading the same Stats — the detect-and-reread + * stance (no lockfile) accepts that. */ /** Derive an ns-scale field from Node's float milliseconds (see module docs). */ From 2c1582bd9ea89521552e080eb1fc74e8e5189160 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:27:00 +0800 Subject: [PATCH 10/46] fix(file-safety): derive the version token from exact BigInt stats (A1, #1375) CodeRabbit finding on this PR: the default numeric fs.stat() loses precision (values above 2^53 are rounded, including Windows file IDs) and the ms->ns derivation introduced a double-precision quantum. Fixed by fetching the stat with { bigint: true }: all five token fields (dev, ino, size, mtimeNs, ctimeNs) are exact BigInt values rendered as decimal strings, with no float anywhere. The sub-ms test now asserts an exact 1_000 ns delta instead of bounded drift, and a regression test pins a size of 10^16+1 (> Number.MAX_SAFE_INTEGER). --- src/utils/__tests__/versionToken.spec.ts | 82 ++++++++++++------------ src/utils/versionToken.ts | 62 +++++++----------- 2 files changed, 65 insertions(+), 79 deletions(-) diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts index 56ce269244..3e2ca26b5c 100644 --- a/src/utils/__tests__/versionToken.spec.ts +++ b/src/utils/__tests__/versionToken.spec.ts @@ -1,25 +1,33 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { computeVersionToken, versionTokenOfStat } from "../versionToken" -// Stats is a class-backed interface without a public constructor, so a plain-object -// test double is the only practical way to pin the token format without real files. -// Last-resort double assertion (test-local, per AGENTS.md). -function makeStats(overrides: Partial = {}): Stats { - const base: Partial = { - dev: 7, - ino: 4242, - size: 1234, - atimeMs: 1_700_000_000_000, - mtimeMs: 1_700_000_000_123.456, - ctimeMs: 1_700_000_000_789.999, - birthtimeMs: 1_700_000_000_000, +// BigIntStats is a class-backed interface without a public constructor, so a +// plain-object test double is the only practical way to pin the token format +// without real files. Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): BigIntStats { + // This repo's @types/node models every StatsBase field (including the *Ms + // fields) as the parameter type T, so all values here are bigint literals; + // the token only reads the *Ns fields. Single-step downcast from Partial to + // the full type (BigIntStats has no public constructor). + const base: Partial = { + dev: 7n, + ino: 4242n, + size: 1234n, + atimeMs: 1_700_000_000_000n, + mtimeMs: 1_700_000_000_123n, + ctimeMs: 1_700_000_000_789n, + birthtimeMs: 1_700_000_000_000n, + atimeNs: 1_700_000_000_000_000_000n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + birthtimeNs: 1_700_000_000_000_000_000n, } - return { ...base, ...overrides } as unknown as Stats + return { ...base, ...overrides } as BigIntStats } describe("versionTokenOfStat (A1, epic #1375)", () => { @@ -27,44 +35,38 @@ describe("versionTokenOfStat (A1, epic #1375)", () => { expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) }) - it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { - const expected = [ - "7", - "4242", - "1234", - Math.round(1_700_000_000_123.456 * 1e6).toString(), - Math.round(1_700_000_000_789.999 * 1e6).toString(), - ].join(":") - expect(versionTokenOfStat(makeStats())).toBe(expected) + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format with exact decimal fields", () => { + expect(versionTokenOfStat(makeStats())).toBe("7:4242:1234:1700000000123456789:1700000000789999999") }) it("distinguishes size changes at identical timestamps", () => { - expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + expect(versionTokenOfStat(makeStats({ size: 1235n }))).not.toBe(versionTokenOfStat(makeStats())) }) - it("distinguishes mtime changes at identical size", () => { - expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + it("distinguishes a one-nanosecond mtime change", () => { + expect(versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_456_790n }))).not.toBe( + versionTokenOfStat(makeStats()), + ) }) it("distinguishes a replaced file (dev/ino change) with identical content state", () => { - const replaced = makeStats({ dev: 8, ino: 999 }) + const replaced = makeStats({ dev: 8n, ino: 999n }) expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) }) - it("preserves sub-ms mtime resolution in the ns field", () => { - const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) - const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) - expect(halfMsLater).not.toBe(wholeMs) - // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at - // this epoch), so allow a bounded drift instead of asserting an exact value. - const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) - expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + it("renders nanosecond resolution exactly (no float quantization)", () => { + const base = versionTokenOfStat(makeStats()) + const plusOneMicrosecond = versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_457_789n })) + // 1_000 ns apart — the BigInt derivation must keep the delta exact. + const baseNs = BigInt(base.split(":")[3]) + const microNs = BigInt(plusOneMicrosecond.split(":")[3]) + expect(microNs - baseNs).toBe(1_000n) }) - it("handles sizes beyond 32 bits without precision loss", () => { - const size = 5_000_000_000 // > 2^32 + it("handles sizes beyond Number.MAX_SAFE_INTEGER without precision loss", () => { + const size = 10_000_000_000_000_001n // 10^16 + 1 > 2^53 const token = versionTokenOfStat(makeStats({ size })) - expect(token).toContain(`:4242:${size}:`) + expect(token).toBe(`7:4242:${size}:1700000000123456789:1700000000789999999`) }) }) @@ -82,9 +84,9 @@ describe("computeVersionToken (A1, epic #1375)", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - it("derives the token from the on-disk state (single stat)", async () => { + it("derives the token from the on-disk state (single bigint stat)", async () => { const token = await computeVersionToken(file) - expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + expect(token).toBe(versionTokenOfStat(await fs.stat(file, { bigint: true }))) }) it("changes when the file content changes", async () => { diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index 59a8b348fe..1738280087 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -1,64 +1,48 @@ import { stat } from "fs/promises" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" /** * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). * * A token is a pure function of a file's on-disk state, derived from a single - * `fs.stat`, so every process that observes the same file state (a second VS Code - * window, the CLI, the user's own editor tooling) computes the same token. The - * downstream guard phases (A2/A3) compare the token observed at read time with the - * token recomputed just before a write to detect "the file changed since the read" - * (stale) or "the file was replaced by a different file" (dev/ino change). + * `fs.stat(path, { bigint: true })`, so every process that observes the same file + * state (a second VS Code window, the CLI, the user's own editor tooling) computes + * the same token. The downstream guard phases (A2/A3) compare the token observed at + * read time with the token recomputed just before a write to detect "the file + * changed since the read" (stale) or "the file was replaced by a different file" + * (dev/ino change). * * Format: `dev:ino:size:mtimeNs:ctimeNs` * - * Resolution note: Node exposes modification/change times as float milliseconds, - * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double - * conversion is correctly rounded, so the derivation is deterministic across - * processes, but it is quantized by double precision (~256 ns at the current epoch). - * Two file states whose timestamps differ by less than the quantum derive the same - * ns field; in practice distinct states differ by at least the OS clock resolution - * (and no write workload produces mtimes closer than that), so the guard contract - * holds: same disk state → same token; changed state → a different token in all - * realistic cases. `dev` and `size` are exact integers. `ino` is Node's - * `number` (float64): exact for small POSIX inode numbers, but on modern Windows - * the underlying file ID exceeds 2^53, so Node's own value is already rounded — - * still deterministic per file (same file → same token), but not guaranteed - * injective across distinct files. Change detection therefore rests on size + - * mtime/ctime: any size change is always detected regardless of the timestamp - * quantum, and a replacement whose size and timestamps are indistinguishable is - * undetectable by any scheme reading the same Stats — the detect-and-reread - * stance (no lockfile) accepts that. + * Precision: the stat is fetched in `bigint` mode, so all five fields are exact + * `BigInt` values rendered as decimal strings — no float is involved anywhere. + * There is therefore no precision loss for large sizes or inodes (a Windows file ID + * exceeds 2^53 and is still exact), and the ns timestamps are the kernel's exact + * nanosecond values rather than a ms→ns derivation (no ~256 ns double-precision + * quantum). Guarantee: same disk state → same token, deterministic across + * processes; any change to size, file identity, or mtime/ctime → a different token. + * + * Platform note: on POSIX `ctime` is the last file-status change; on Windows it is + * the file creation time. The token only requires it to move when the file's + * metadata is replaced, which holds on both. */ -/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ -function nsFromMs(ms: number): string { - return Math.round(ms * 1e6).toString() -} - /** - * Build the version token from an already-fetched `Stats` — no I/O. + * Build the version token from an already-fetched `BigIntStats` — no I/O. * * Exported separately from {@link computeVersionToken} so tests can pin the exact * format against synthetic stats. */ -export function versionTokenOfStat(stats: Stats): string { - return [ - stats.dev.toString(), - stats.ino.toString(), - stats.size.toString(), - nsFromMs(stats.mtimeMs), - nsFromMs(stats.ctimeMs), - ].join(":") +export function versionTokenOfStat(stats: BigIntStats): string { + return [stats.dev, stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => value.toString()).join(":") } /** - * Compute the version token for a file (one `fs.stat`). + * Compute the version token for a file (one `fs.stat` in bigint mode). * * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; * how an unobservable target is treated is decided by the guard layer (A3). */ export async function computeVersionToken(filePath: string): Promise { - return versionTokenOfStat(await stat(filePath)) + return versionTokenOfStat(await stat(filePath, { bigint: true })) } From a3c6f86ba4e0a54dc78f9dd3c8b17ae5ba628b3c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:43:04 +0800 Subject: [PATCH 11/46] feat(experiments): make chat-diff the default approval path (L2, #1375) Flips the PREVENT_FOCUS_DISRUPTION experiment default from false to true so the chat-diff approval path (approve in chat, save directly, no diff-editor focus) is the default. The diff-editor path remains available by toggling the experiment off; the storage key is unchanged so saved values are preserved. Production call sites already route through experiments.isEnabled(... ?? {}), so the flip applies automatically. Test-only changes pin the legacy path explicitly where base mocks relied on the old default. --- src/core/tools/__tests__/editFileTool.spec.ts | 4 +- src/core/tools/__tests__/editTool.spec.ts | 37 ++++++++++++++++++- .../tools/__tests__/searchReplaceTool.spec.ts | 4 +- .../tools/__tests__/writeToFileTool.spec.ts | 3 ++ .../editor/__tests__/DiffViewProvider.spec.ts | 5 ++- ...experiments-preventFocusDisruption.spec.ts | 12 +++--- src/shared/__tests__/experiments.spec.ts | 4 +- src/shared/experiments.ts | 5 ++- 8 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1ff8d52a8d..30244560f4 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -115,7 +115,9 @@ describe("editFileTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: {}, + // Pin the legacy diff-editor path explicitly: the PREVENT_FOCUS_DISRUPTION default + // flipped to true (L2, plan #33), so these tests must opt out. + experiments: { preventFocusDisruption: false } as Record, }), }), } diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index a5f665b9e5..8be210fe7a 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -111,7 +111,9 @@ describe("editTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: {}, + // Pin the legacy diff-editor path explicitly: the PREVENT_FOCUS_DISRUPTION default + // flipped to true (L2, plan #33), so these tests must opt out. + experiments: { preventFocusDisruption: false } as Record, }), }), } @@ -361,6 +363,39 @@ describe("editTool", () => { }) }) + describe("focus disruption default (L2: chat-diff is the default approval path)", () => { + it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { + mockAskApproval.mockResolvedValue(true) + // No stored experiment value: the default (flipped to true in L2) resolves + // to the chat-diff path. + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }) + + await executeEditTool() + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockTask.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + }) + + it("still uses the diff-editor path when the user has opted out (stored false)", async () => { + mockAskApproval.mockResolvedValue(true) + // Base mock pins experiments: { preventFocusDisruption: false } (legacy path). + + await executeEditTool() + + expect(mockTask.diffViewProvider.open).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + }) + }) + describe("partial block handling", () => { it("handles partial block without errors after path stabilizes", async () => { // Path stabilization requires two consecutive calls with the same path diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 5cf10790d4..d83c5c5aa5 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -113,7 +113,9 @@ describe("searchReplaceTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: {}, + // Pin the legacy diff-editor path explicitly: the PREVENT_FOCUS_DISRUPTION default + // flipped to true (L2, plan #33), so these tests must opt out. + experiments: { preventFocusDisruption: false } as Record, }), }), } diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..62c00427fb 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -137,6 +137,9 @@ describe("writeToFileTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, + // Pin the legacy diff-editor path explicitly: the PREVENT_FOCUS_DISRUPTION default + // flipped to true (L2, plan #33), so these tests must opt out. + experiments: { preventFocusDisruption: false }, }), }), } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..469fcffa2f 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -811,15 +811,16 @@ describe("DiffViewProvider", () => { expect(result.finalContent).toBe("new content") }) - it("should not open file when openWithoutFocus is false", async () => { + it("should not open file when openWithoutFocus is false (focus not stolen: in-memory document only)", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) // Verify file was written const fs = await import("fs/promises") expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") - // Verify file was NOT opened + // Verify file was NOT opened in the editor, and the document is loaded in memory only expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + expect(vscode.workspace.openTextDocument).toHaveBeenCalled() }) it("should skip diagnostics when diagnosticsEnabled is false", async () => { diff --git a/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts b/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts index e9f96c7ce7..44a6e237c0 100644 --- a/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts +++ b/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts @@ -5,13 +5,13 @@ describe("PREVENT_FOCUS_DISRUPTION experiment", () => { expect(EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION).toBe("preventFocusDisruption") }) - it("should have PREVENT_FOCUS_DISRUPTION in experimentConfigsMap", () => { + it("should have PREVENT_FOCUS_DISRUPTION enabled by default (chat-diff is the default approval path)", () => { expect(experimentConfigsMap.PREVENT_FOCUS_DISRUPTION).toBeDefined() - expect(experimentConfigsMap.PREVENT_FOCUS_DISRUPTION.enabled).toBe(false) + expect(experimentConfigsMap.PREVENT_FOCUS_DISRUPTION.enabled).toBe(true) }) - it("should have PREVENT_FOCUS_DISRUPTION in experimentDefault", () => { - expect(experimentDefault.preventFocusDisruption).toBe(false) + it("should have PREVENT_FOCUS_DISRUPTION enabled in experimentDefault", () => { + expect(experimentDefault.preventFocusDisruption).toBe(true) }) it("should correctly check if PREVENT_FOCUS_DISRUPTION is enabled", () => { @@ -23,8 +23,8 @@ describe("PREVENT_FOCUS_DISRUPTION experiment", () => { const enabledConfig = { preventFocusDisruption: true } expect(experiments.isEnabled(enabledConfig, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) - // Test when experiment is not in config (should use default) + // Test when experiment is not in config (should use default — now enabled) const emptyConfig = {} - expect(experiments.isEnabled(emptyConfig, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) + expect(experiments.isEnabled(emptyConfig, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) }) diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index f2261a5c09..6e2f75090c 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -6,10 +6,10 @@ import { EXPERIMENT_IDS, experimentConfigsMap, experiments as Experiments } from describe("experiments", () => { describe("PREVENT_FOCUS_DISRUPTION", () => { - it("is configured correctly", () => { + it("is configured correctly (chat-diff is the default approval path)", () => { expect(EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION).toBe("preventFocusDisruption") expect(experimentConfigsMap.PREVENT_FOCUS_DISRUPTION).toMatchObject({ - enabled: false, + enabled: true, }) }) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index ae538b9138..f8fb132394 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -19,7 +19,10 @@ interface ExperimentConfig { } export const experimentConfigsMap: Record = { - PREVENT_FOCUS_DISRUPTION: { enabled: false }, + // Chat-diff is the default approval path (plan issue #33, L2): writes are saved + // without opening/refocusing the diff editor. The diff-editor path remains + // available by toggling this experiment off (storage key unchanged). + PREVENT_FOCUS_DISRUPTION: { enabled: true }, IMAGE_GENERATION: { enabled: false }, RUN_SLASH_COMMAND: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, From 165bff4dd9ae126788fb60942d35d13bff6dcd48 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 12:18:16 +0800 Subject: [PATCH 12/46] test(experiments): cover unset-experiment default routing in all write tool suites (L2, #1375) --- src/core/tools/__tests__/editFileTool.spec.ts | 22 ++++++++++++++++++ .../tools/__tests__/searchReplaceTool.spec.ts | 22 ++++++++++++++++++ .../tools/__tests__/writeToFileTool.spec.ts | 23 +++++++++++++++++++ .../editor/__tests__/DiffViewProvider.spec.ts | 2 +- 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 30244560f4..cbececbbd0 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -578,6 +578,28 @@ describe("editFileTool", () => { }) }) + describe("focus disruption default (L2: chat-diff is the default approval path)", () => { + it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { + mockAskApproval.mockResolvedValue(true) + // No stored experiment value: the default (flipped to true in L2) resolves + // to the chat-diff path. + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }) + + await executeEditFileTool() + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockTask.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + }) + }) + describe("partial block handling", () => { it("handles partial block without errors after path stabilizes", async () => { // Path stabilization requires two consecutive calls with the same path diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index d83c5c5aa5..981caa350e 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -332,6 +332,28 @@ describe("searchReplaceTool", () => { }) }) + describe("focus disruption default (L2: chat-diff is the default approval path)", () => { + it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { + mockAskApproval.mockResolvedValue(true) + // No stored experiment value: the default (flipped to true in L2) resolves + // to the chat-diff path. + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }) + + await executeSearchReplaceTool() + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(true) + }) + }) + describe("partial block handling", () => { it("handles partial block without errors after path stabilizes", async () => { // Path stabilization requires two consecutive calls with the same path diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 62c00427fb..47234ad253 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -159,6 +159,7 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue(undefined), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -367,6 +368,28 @@ describe("writeToFileTool", () => { }) }) + describe("focus disruption default (L2: chat-diff is the default approval path)", () => { + it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { + mockAskApproval.mockResolvedValue(true) + // No stored experiment value: the default (flipped to true in L2) resolves + // to the chat-diff path. + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }) + + await executeWriteFileTool() + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(true) + }) + }) + describe("file operations", () => { it("successfully creates new files with full workflow", async () => { await executeWriteFileTool({}, { fileExists: false }) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 469fcffa2f..36e698f0d2 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -820,7 +820,7 @@ describe("DiffViewProvider", () => { // Verify file was NOT opened in the editor, and the document is loaded in memory only expect(vscode.window.showTextDocument).not.toHaveBeenCalled() - expect(vscode.workspace.openTextDocument).toHaveBeenCalled() + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(`${mockCwd}/test.ts`)) }) it("should skip diagnostics when diagnosticsEnabled is false", async () => { From c0ef476be42ec3eb7314934c9d25c8ac07370319 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 12:25:25 +0800 Subject: [PATCH 13/46] test(experiments): add explicit enabled-state routing tests for chat-diff default (L2, #1375) --- src/core/tools/__tests__/editFileTool.spec.ts | 19 +++++++++++++++++++ .../tools/__tests__/searchReplaceTool.spec.ts | 19 +++++++++++++++++++ .../tools/__tests__/writeToFileTool.spec.ts | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index cbececbbd0..2d88649548 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -598,6 +598,25 @@ describe("editFileTool", () => { expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) }) + + it("saves via saveDirectly when the user has explicitly enabled the experiment", async () => { + mockAskApproval.mockResolvedValue(true) + // Explicit stored true: same chat-diff routing as the default. + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + await executeEditFileTool() + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockTask.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + }) }) describe("partial block handling", () => { diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 981caa350e..10b8c9028a 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -352,6 +352,25 @@ describe("searchReplaceTool", () => { expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockCline.didEditFile).toBe(true) }) + + it("saves via saveDirectly when the user has explicitly enabled the experiment", async () => { + mockAskApproval.mockResolvedValue(true) + // Explicit stored true: same chat-diff routing as the default. + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + await executeSearchReplaceTool() + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(true) + }) }) describe("partial block handling", () => { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 47234ad253..37a6b7b102 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -388,6 +388,25 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() expect(mockCline.didEditFile).toBe(true) }) + + it("saves via saveDirectly when the user has explicitly enabled the experiment", async () => { + mockAskApproval.mockResolvedValue(true) + // Explicit stored true: same chat-diff routing as the default. + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + await executeWriteFileTool() + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() + expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(true) + }) }) describe("file operations", () => { From 2965ad18e867285318ae5fa3748ab94ce453782f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 14:52:58 +0800 Subject: [PATCH 14/46] feat(task): per-task file observation registry (A2, #1375) --- src/core/task/Task.ts | 2 + .../__tests__/observationRegistry.spec.ts | 72 +++++++++++ src/core/task/observationRegistry.ts | 47 ++++++++ src/core/tools/ReadFileTool.ts | 12 ++ src/core/tools/__tests__/readFileTool.spec.ts | 113 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/core/task/__tests__/observationRegistry.spec.ts create mode 100644 src/core/task/observationRegistry.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..8977b60830 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -103,6 +103,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" +import { ObservationRegistry } from "./observationRegistry" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" @@ -181,6 +182,7 @@ export class Task extends EventEmitter implements TaskLike { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string + readonly observationRegistry = new ObservationRegistry() /** * The mode associated with this task. Persisted across sessions diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..51b73aabde --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest" + +import { ObservationRegistry } from "../observationRegistry" + +describe("ObservationRegistry", () => { + it("observe → get returns the recorded version and observedAt", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") + + const obs = reg.get("/a/b/c.ts") + expect(obs).toBeDefined() + expect(obs!.version).toBe("1:2:300:4000000000:5000000000") + expect(typeof obs!.observedAt).toBe("number") + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + const first = reg.get("/a/b/c.ts")! + expect(first.version).toBe("v1") + + vi.advanceTimersByTime(50) + reg.observe("/a/b/c.ts", "v2") + const second = reg.get("/a/b/c.ts")! + expect(second.version).toBe("v2") + expect(second.observedAt).toBeGreaterThan(first.observedAt) + + vi.useRealTimers() + }) + + it("has returns true for observed paths, false otherwise", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + expect(reg.has("/x.ts")).toBe(true) + expect(reg.has("/y.ts")).toBe(false) + }) + + it("size reflects the number of observed entries", () => { + const reg = new ObservationRegistry() + expect(reg.size).toBe(0) + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + expect(reg.size).toBe(2) + }) + + it("clear removes all entries and resets size to 0", () => { + const reg = new ObservationRegistry() + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + reg.clear() + expect(reg.size).toBe(0) + expect(reg.get("/a.ts")).toBeUndefined() + expect(reg.has("/b.ts")).toBe(false) + }) + + it("get on empty registry returns undefined", () => { + const reg = new ObservationRegistry() + expect(reg.get("/any.ts")).toBeUndefined() + }) + + it("separate instances are independent — observing in one does not appear in the other", () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")).toBeDefined() + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..871f80225b --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,47 @@ +/** + * Per-task file observation registry (upstream epic #1375, phase A2). + * + * Each Task owns its own instance so parent and subtask observations are + * independent. The S4 guarded-write will compare these versions against the + * token recomputed pre-write to detect stale reads or file replacement. + * + * Pure in-memory — zero I/O, no dependencies. No behavior change in this PR: + * observations are recorded but not consulted. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number +} + +export class ObservationRegistry { + private readonly entries = new Map() + + /** + * Record an observation for a file at its absolute path. + * + * Re-observing replaces the entry with a fresh observedAt timestamp and + * the new version token. + */ + observe(absolutePath: string, version: string): void { + this.entries.set(absolutePath, { version, observedAt: Date.now() }) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } + + has(absolutePath: string): boolean { + return this.entries.has(absolutePath) + } + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } +} diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..6e222e1309 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,6 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { computeVersionToken } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -220,6 +221,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) + updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, }) @@ -799,6 +805,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + // A2 (plan #33 / epic #1375): mirror the native path — record the observed + // on-disk version so legacy-format reads also feed the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..6108e78151 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -1489,5 +1499,108 @@ describe("ReadFileTool", () => { expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + + describe("observation registry", () => { + it("records an observation on successful read of an existing file", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Override the beforeEach default stat mock with proper BigIntStats. + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + // Spy on observe to capture the exact key used (Windows path.resolve may use backslashes). + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // Verify the tool called observe exactly once with a valid token. + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("existing.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Verify get() returns the same data using the spy-captured key. + const obs = reg.get(calledPath) + expect(obs).toBeDefined() + expect(obs!.version).toBe(calledVersion) + }) + + it("a failed read (absent path) leaves the registry size 0 and does not throw", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "missing.ts" }, mockTask as unknown as Task, callbacks) + + // observationRegistry is guaranteed present because we passed it in createMockTask. + const reg = mockTask.observationRegistry + expect(reg).toBeDefined() + expect(reg!.size).toBe(0) + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("legacy.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("two separate Task-owned registries are independent", async () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + }) }) }) From a37dd24f128e38aa4c4261d72ed580de73429fe2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 15:11:34 +0800 Subject: [PATCH 15/46] feat(file-safety): atomic text publish primitive + safeWriteJson refactor (A4, #1375) --- src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 3 +- .../editor/__tests__/DiffViewProvider.spec.ts | 28 +- .../__tests__/safeWriteText.spec.ts | 614 ++++++++++++++++++ src/services/file-safety/safeWriteText.ts | 307 +++++++++ src/utils/__tests__/safeWriteJson.test.ts | 87 ++- src/utils/safeWriteJson.ts | 108 +-- 7 files changed, 1040 insertions(+), 109 deletions(-) create mode 100644 src/services/file-safety/__tests__/safeWriteText.spec.ts create mode 100644 src/services/file-safety/safeWriteText.ts diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 36cbfeac5b..77680449be 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1721,7 +1721,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..36f5323f19 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { safeWriteText } from "../../services/file-safety/safeWriteText" import { DecorationController } from "./DecorationController" @@ -1156,7 +1157,7 @@ export class DiffViewProvider { // Write the content directly to the file await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await safeWriteText(absolutePath, content) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index aee88f4061..511f0e7f3c 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -15,6 +15,14 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), })) // Mock utils @@ -26,6 +34,8 @@ vi.mock("../../../utils/fs", () => ({ vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), })) // Mock vscode @@ -791,9 +801,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -814,9 +824,9 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() @@ -829,9 +839,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts new file mode 100644 index 0000000000..4accc2b71e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,614 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import { execFile } from "child_process" +import type { ChildProcess } from "child_process" +import * as path from "path" + +import { safeWriteText, type SafeWriteTextOptions } from "../safeWriteText" + +// Full mock for fs/promises — all methods are vi.fn() stubs +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + access: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + realpath: vi.fn(), +})) + +// Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare +// class stub so tests can build minimal Stats stand-ins via its prototype. +vi.mock("fs", () => ({ + openSync: vi.fn(), + writeSync: vi.fn(), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + fsyncSync: vi.fn(), + chmodSync: vi.fn(), + fchmodSync: vi.fn(), + statSync: vi.fn(), + Stats: class Stats {}, +})) + +// Mock child_process.execFile (callback-based — must invoke callback to resolve) +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +// Minimal stand-in for the ChildProcess that callback-form execFile returns. +const fakeChild = { kill: () => true } as unknown as ChildProcess + +// Helper that mirrors safeWriteText's path resolution exactly +function _resolvedTarget(filePath: string): string { + return path.resolve(filePath) +} +function _dirPath(filePath: string): string { + return path.dirname(_resolvedTarget(filePath)) +} +function _stagingDir(dir: string): string { + return path.join(dir, ".file-safety-staging") +} + +// Minimal Stats stand-in: the SUT only reads `.mode` from it. +function _stats(mode: number): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + Object.assign(s, { mode }) + return s +} + +// ── Test 1: staging file created then cleaned after success ──────────────── + +describe("safeWriteText", () => { + beforeEach(() => { + vi.resetAllMocks() + // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Default sync-write behaviour: report that all requested bytes were + // written. The Buffer overload passes (fd, buffer, offset, length), + // so the fourth argument is the requested length. + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) + }) + + describe("staging and cleanup", () => { + it("creates a temp file in the staging dir, fsyncs it, renames to target, and cleans up on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) // fd=1 + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + // staging dir was created with private permissions — use + // stringContaining to handle Windows path resolution + expect(fsSync.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), { + recursive: true, + mode: 0o700, + }) + // a pre-existing staging dir is repaired to private permissions too + expect(fsSync.chmodSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), 0o700) + + // temp file was opened for writing with the existing target's mode + // (default 0o644 from the statSync default mock) + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + + // content was written as a buffer (partial-write loop, full write) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hello world", "utf8"), 0, 11) + + // fsync (sync form) was called on the fd + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // file was closed + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + + // atomic rename happened — realpath mock returns targetPath, so that's the dest + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 2: fsync ordering ─────────────────────────────────────────────── + + describe("fsync ordering", () => { + it("calls fsync on the fd before close, and rename after close", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Verify call order: openSync(temp) → writeSync → fsyncSync(temp) + // → closeSync(temp) → rename. On POSIX the parent directory is then + // opened and fsynced after the commit rename, so openSync/fsyncSync/ + // closeSync each have a second (directory) call. + expect(vi.mocked(fsSync.openSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.writeSync).mock.calls.length).toBe(1) + expect(vi.mocked(fsSync.fsyncSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.closeSync).mock.calls.length).toBe(2) + + // the temp file was fully closed before the commit rename + expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) + expect(fs.rename).toHaveBeenCalled() + }) + }) + + // ── Test 3: simulated failure between write and rename leaves target intact ── + + describe("crash/torn-write safety", () => { + it("simulated failure between fsync and rename leaves the target byte-identical and no temp left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("ENOSPC") + + // rename was attempted (the failure point) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // temp file was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + + // backup was NOT created (backup:false by default), so target is untouched + // The only rename call was temp→target, not a rollback rename + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + + it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The post-commit backup unlink (SUT step 6) fails — the write must + // still succeed; an orphaned backup is the documented acceptable + // outcome, so the failure is swallowed instead of rolling back. + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { backup: true, platform: "linux" }) + + // the commit rename (temp -> target) still happened + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // the failing cleanup was the post-commit backup unlink + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + + // no rollback rename: the committed target is not restored from the backup + expect(fs.rename).toHaveBeenCalledTimes(2) + + // the staging temp was already committed by the rename; nothing + // temp-shaped is unlinked afterwards + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + }) + + // ── Test 4: backup:true keeps old safeWriteJson semantics incl. rollback ── + + describe("backup:true", () => { + it("renames target -> backup before commit, deletes backup on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // target was accessed (exists check) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // first rename: target -> backup + expect(fs.rename).toHaveBeenNthCalledWith(1, targetPath, expect.stringContaining("safeWriteText.bak_")) + + // second rename: temp -> target (realpath mock returns targetPath) + expect(fs.rename).toHaveBeenNthCalledWith(2, expect.stringContaining("safeWriteText_"), targetPath) + + // backup was deleted on success + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("rollback: on failure after rename target->backup, restores backup to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // first rename (target->backup) succeeds, second fails + let callCount = 0 + vi.mocked(fs.rename).mockImplementation(async () => { + callCount++ + if (callCount === 1) return // target -> backup + throw new Error("ENOSPC") // temp -> target fails + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") + + // rollback rename is the 3rd call (after target->backup and temp->target failure) + expect(fs.rename).toHaveBeenNthCalledWith(3, expect.stringContaining("safeWriteText.bak_"), targetPath) + + // temp was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("backup:true when target does not exist: no backup created, just commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // fs.access resolves for dirPath check, but rejects for target check (backup path) + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + }) + + await safeWriteText(targetPath, "new data", { backup: true, platform: "linux" }) + + // no backup rename (target didn't exist) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // only one rename: temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink (no backup to delete; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 5: win32 DACL path ────────────────────────────────────────────── + + describe("win32 DACL", () => { + it.skipIf(process.platform !== "win32")( + "copies target DACL onto staging file via icacls before rename on Windows", + async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls dump + restore were called (execFile is callback-based mock) + expect(execFile).toHaveBeenCalledTimes(2) + }, + ) + + it("non-win32: DACL path is unreachable when platform is not win32", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // icacls was NOT called on non-win32 + expect(execFile).not.toHaveBeenCalled() + }) + + it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls dump fails — the callback-based mock must invoke cb with an error. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite icacls failure (fallback to plain rename) + expect(fs.rename).toHaveBeenCalled() + }) + + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + // icacls was called twice (save + restore) + expect(execFile).toHaveBeenCalledTimes(2) + + // First call: save DACL from target before backup rename + const firstCall = vi.mocked(execFile).mock.calls[0] + expect(firstCall[0]).toBe("icacls") + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining(".acl.tmp"), "/T"]) + + // Second call: restore DACL onto directory after commit rename + const secondCall = vi.mocked(execFile).mock.calls[1] + expect(secondCall[0]).toBe("icacls") + expect(secondCall[1]).toEqual([ + expect.stringContaining("/tmp/test-dir"), + "/restore", + expect.stringContaining(".acl.tmp"), + ]) + + // dump file was unlinked after restore + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: dump is unlinked even when restore fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // icacls save succeeds, restore fails + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 1 ? null : new Error("icacls restore error"), "", "") + } + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite restore failure (best-effort) + expect(fs.rename).toHaveBeenCalled() + + // dump file was still unlinked in finally + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(".acl.tmp")) + }) + + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // fs.access rejects for targetPath (ENOENT), but resolves for dirPath + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + return undefined + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls was NOT called (target absent → skip DACL entirely) + expect(execFile).not.toHaveBeenCalled() + + // no dump file created or unlinked + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 6: pre-written temp path (tempPath option) ────────────────────── + + describe("pre-written temp path", () => { + it("uses the provided tempPath, fsyncs it, and renames to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + const customTempPath = "/tmp/custom-temp.tmp" + + // platform:linux skips DACL entirely so this test focuses on tempPath only + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // openSync was called on the custom temp path (r+ mode for fsync) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + + // fsync was called + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // rename happened — realpath mock returns targetPath + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + + // no unlink of custom temp (caller's concern; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + + // a caller-supplied tempPath must not create the staging directory + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + }) + + it("applies the existing target's mode to a caller-supplied tempPath before publishing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // the caller-staged temp is fchmod'd to the restrictive target mode so + // the atomic rename cannot widen a 0o600 target (CWE-732 regression) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("keeps the temp's default mode when the target does not exist yet (ENOENT)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // no existing target, so nothing to preserve and no fchmod on the temp + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = "/tmp/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // a 0o444 target must not make openSync(tempPath, "r+") fail: the mode + // is applied with fchmodSync on the already-open fd, after the open + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fsSync.fchmodSync).toHaveBeenCalledWith(3, 0o444) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + }) + + // ── Test 7: symlink handling (Finding 4 regression test) ───────────────── + + describe("symlink handling", () => { + it("a write through a symlink commits onto the resolved referent, never the link path", async () => { + const linkPath = "/tmp/links/link.txt" + const referentPath = "/tmp/targets/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(referentPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "new-content", { platform: "linux" }) + + // The commit rename must target the realpath result (the referent), never the link itself — + // that is what guarantees a write through a symlink replaces the referent's content + // and preserves the link. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referentPath) + expect(fs.rename).not.toHaveBeenCalledWith(expect.anything(), linkPath) + }) + + it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // rename still happened with the fallback path (path.resolve on /tmp → C:\tmp) + const resolvedFallback = _resolvedTarget(targetPath) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) + }) + }) + + // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── + + describe("review fixes", () => { + it("preserves the target's restrictive mode and tolerates a failed staging-dir permission repair", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + // a pre-existing staging dir may fail its best-effort permission repair + vi.mocked(fsSync.chmodSync).mockImplementationOnce(() => { + throw new Error("EACCES") + }) + + await safeWriteText(targetPath, "secret", { platform: "linux" }) + + // the staging file inherits the target's 0o600 mode and the write commits + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o600) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("falls back to the 0o644 default when the target does not exist yet", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + + await safeWriteText(targetPath, "fresh", { platform: "linux" }) + + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + }) + + it("loops on short writes until the full content is durable before fsync", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const content = "0123456789" // 10 bytes + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const buffer = Buffer.from(content, "utf8") + // first write (offset 0) reports 4 bytes (short write); the loop continues + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + args[2] === 0 ? 4 : typeof args[3] === "number" ? args[3] : 0, + ) + + await safeWriteText(targetPath, content, { platform: "linux" }) + + // [0,10) reports 4 bytes, then [4,10) writes the remaining 6 + expect(fsSync.writeSync).toHaveBeenCalledTimes(2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 10) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6) + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("fsyncs the parent directory after the commit rename on POSIX", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // temp fd=1 then parent-dir fd=2 - distinct fds prove the ordering + vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // the directory fsync (fd 2) happens only after the file fsync (fd 1); + // the dir path assertion is path-agnostic (stringContaining) because + // path.dirname renders the same input differently on Windows + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("test-dir"), "r") + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(1, 1) + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(2, 2) + expect(fsSync.closeSync).toHaveBeenCalledWith(2) + }) + + it("treats a failed parent-directory fsync as best-effort", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + + // the content rename already committed; a missing directory fsync is not fatal + await safeWriteText(targetPath, "data", { platform: "linux" }) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("propagates realpath errors (EACCES and code-less) instead of the fallback path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(eacces) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + + const plain = new Error("resolution failed") + vi.mocked(fs.realpath).mockRejectedValueOnce(plain) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("backup:true propagates access errors (EACCES and code-less) instead of skipping the backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }) + const plain = new Error("access failed") + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // each write accesses dirPath then target; only the target access rejects + const rejectTarget = (error: Error) => async (p: unknown) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw error + } + vi.mocked(fs.access) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(plain)) + .mockImplementationOnce(rejectTarget(plain)) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toEqual( + expect.objectContaining({ code: "EACCES" }), + ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + "access failed", + ) + expect(fs.rename).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..71871032e5 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,307 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import { execFile } from "child_process" + +/** + * Options for safeWriteText atomic text publish primitive. + */ +export interface SafeWriteTextOptions { + /** + * When true, preserve the old-file semantics: rename target -> backup first, + * after commit rename delete the backup; on failure roll the backup back to + * the target path. When false (default) the atomic rename simply replaces + * the target -- crash-safe window is zero. + */ + backup?: boolean + + /** + * Platform override for testing. When omitted the real process.platform + * value is used. Set to "win32" or "linux" / "darwin" from tests so that + * both branches are reachable without needing a real Windows runner. + */ + platform?: string + + /** + * Custom execFile runner for testing (e.g. vi.fn). When omitted the real + * child_process.execFile is used. + */ + execFileRunner?: typeof execFile + + /** + * Pre-written temp path to use for the commit phase. When provided, + * safeWriteText skips creating its own staging file and uses this path + * instead (it still fsyncs before rename). Useful when a caller has + * already written data to a temp file via a custom stream. + */ + tempPath?: string +} + +// -- helpers --------------------------------------------------------------- + +/** Generate a unique temp file name in the given directory. */ +function _tempName(dir: string, prefix: string): string { + return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") +} + +/** Create a private staging sub-directory inside *dir* so that multiple + * concurrent writes never collide on their temp names. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging") + // mode:0o700 protects a freshly created staging dir; the best-effort chmod + // repairs a pre-existing one (mkdirSync with recursive:true never chmods an + // existing directory), so staged temp files are never group/world readable. + fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 }) + try { + fsSync.chmodSync(sd, 0o700) + } catch { + // best-effort: chmod denied or unavailable; a fresh dir was still + // created with the requested mode + } + return sd +} + +/** + * fsync a file descriptor so its data is durable before the atomic rename. + * Uses the sync form because this repo's @types/node does not declare + * fs.promises.fsync; the staging file is small, so the blocking window is bounded. + */ +function _fsyncFile(fd: number): void { + fsSync.fsyncSync(fd) +} + +/** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when the dump was written successfully; false otherwise. + * Never throws — callers treat failure as "skip DACL handling". */ +async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [srcPath, "/save", dumpPath, "/T"], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + return true + } catch { + return false + } +} + +/** Restore a DACL dump onto *dirPath* on Windows. + * Best-effort: content is already committed, so failure is non-fatal. */ +async function _restoreDaclWindows(dirPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner("icacls", [dirPath, "/restore", dumpPath], { windowsHide: true }, (err) => + err ? reject(err) : resolve(), + ) + }) + } catch { + // best-effort; content already committed + } +} + +// -- public API ------------------------------------------------------------ + +/** + * Atomic text publish primitive. + * + * 1. Write content to a temp file in a private per-write staging subdir + * (same volume -> atomic rename guaranteed). + * 2. fsync the temp file, then close it. + * 3. win32 only: if target exists save its DACL dump BEFORE backup rename. + * 4. Optionally rename target -> backup (when backup:true). + * 5. Atomic rename temp -> target. + * 6. win32 only: restore DACL onto the directory AFTER commit rename. + * 7. On success: delete backup (if any) and unlink DACL dump. + * 8. On failure: rollback backup to target path; clean up temp + dump. + */ + +/** + * Resolve the publish target: the symlink referent when the given path is an + * existing symlink, the path itself otherwise. Only ENOENT (target absent yet) + * may fall back to the given path; any other resolution error (EACCES, EIO, ...) + * propagates so a broken or unreadable symlink is never written through its + * link path. Callers that stage a temp file themselves must stage it beside + * the resolved path: the commit is a rename onto the referent, and a rename + * across filesystems fails with EXDEV. + */ +export async function resolvePublishTarget(absoluteFilePath: string): Promise { + return fs.realpath(absoluteFilePath).catch((error: unknown) => { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined + if (code !== "ENOENT") throw error + return absoluteFilePath + }) +} + +export async function safeWriteText(filePath: string, content: string, options?: SafeWriteTextOptions): Promise { + const absoluteFilePath = path.resolve(filePath) + + // Resolve the symlink referent (see resolvePublishTarget). + const targetPath = await resolvePublishTarget(absoluteFilePath) + const dirPath = path.dirname(targetPath) + + // Ensure parent directory exists (mirrors safeWriteJson behaviour). + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + // Create the staging directory only when we generate the temp file there; + // callers supplying their own tempPath (e.g. safeWriteJson) must not be left + // with an empty .file-safety-staging directory behind. + const tempPath = options?.tempPath ?? _tempName(_stagingDir(dirPath), "safeWriteText") + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + let daclDumpPath: string | null = null // tracked for cleanup in finally + + try { + // -- Step 1: write content to staging temp file ------------------- + if (!options?.tempPath) { + // Preserve the existing target's permissions: the staging file must + // not be published wider than the file it replaces (a 0o600 target + // must not become 0o644 through the atomic rename). + let targetMode = 0o644 // default for a fresh target + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the default + } + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + // Loop until every byte is written: writeSync can report a short + // (partial) write, and publishing a truncated staging file would + // commit corrupt content. + const buffer = Buffer.from(content, "utf8") + let offset = 0 + while (offset < buffer.length) { + offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } else { + // Preserve the existing target's mode (CWE-732): the caller-staged + // temp carries its own creation mode, and publishing it as-is would + // widen a restrictive target (e.g. 0o600 -> 0o644) through rename. + // The mode is applied with fchmodSync on the open fd (AFTER openSync): + // chmodSync on the path before the open would make a read-only target + // (0o400/0o444) fail openSync(tempPath, "r+") with EACCES. + let targetMode: number | null = null + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch { + // target does not exist yet - keep the temp's default mode + } + const fd = fsSync.openSync(tempPath, "r+") + try { + if (targetMode !== null) { + fsSync.fchmodSync(fd, targetMode) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } + + // -- Step 2 (win32): save DACL BEFORE backup rename --------------- + const platform = options?.platform ?? process.platform + if (platform === "win32") { + try { + await fs.access(targetPath) // target exists? + daclDumpPath = targetPath + ".acl.tmp" + const saved = await _saveDaclWindows(targetPath, daclDumpPath, options?.execFileRunner) + if (!saved) { + daclDumpPath = null // skip DACL handling entirely + } + } catch { + // target does not exist or access failed — no DACL handling + daclDumpPath = null + } + } + + try { + // -- Step 3 (backup:true): rename target -> backup -------------- + if (options?.backup) { + try { + await fs.access(targetPath) + backupPath = _tempName(dirPath, "safeWriteText.bak") + await fs.rename(targetPath, backupPath) + releaseBackupOnSuccess = true + } catch (err: unknown) { + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: string }).code + : undefined + if (code !== "ENOENT") throw err + } + } + + // -- Step 4: atomic rename temp -> target --------------------- + await fs.rename(tempPath, targetPath) + + // -- Step 4b (POSIX): fsync the parent directory so the directory entry + // changed by the commit rename is durable, not just the file content. + if (platform !== "win32") { + try { + const dirFd = fsSync.openSync(dirPath, "r") + try { + _fsyncFile(dirFd) + } finally { + fsSync.closeSync(dirFd) + } + } catch { + // best-effort: the content rename already committed + } + } + + // -- Step 5 (win32): restore DACL AFTER commit rename --------- + if (platform === "win32" && daclDumpPath !== null) { + const restoredDir = path.dirname(targetPath) + await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + } + + // -- Step 6 (backup:true): delete backup on success ----------- + if (releaseBackupOnSuccess && backupPath) { + try { + await fs.unlink(backupPath) + } catch { + // non-fatal — orphaned backup is acceptable + } + } + } finally { + // Unlink DACL dump regardless of success/failure in this span. + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + } + + // tempPath is now the committed file; no cleanup needed. + } catch (originalError: unknown) { + // -- Rollback / cleanup on failure ---------------------------------- + if (backupPath && releaseBackupOnSuccess) { + try { + await fs.rename(backupPath, targetPath) + } catch { + // rollback failed — do not mask original error + } + } + + // Always clean up the staging temp file on failure. + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..064207e21f 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -312,9 +312,8 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for console error suppression during backup deletion - test("should suppress console.error when backup deletion fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error + // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) + test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { const initialData = { message: "Initial" } const newData = { message: "New" } @@ -322,18 +321,23 @@ describe("safeWriteJson", () => { // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes(".bak_")) { + if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText and never masks the committed content. await safeWriteJson(currentTestFilePath, newData) - // Verify console.error was called with the expected message - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Successfully wrote"), expect.any(Error)) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) + + // The orphaned backup is still on disk because its deletion failed. + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -434,9 +438,9 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario - test("should log error and re-throw original if rollback fails", async () => { - const initialData = { message: "Initial, should be lost if rollback fails" } + // Test for rollback failure scenario (the rollback rename now lives in safeWriteText) + test("re-throws the original error when the rollback rename fails, leaving an orphaned backup", async () => { + const initialData = { message: "Initial, orphaned when rollback fails" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -451,20 +455,20 @@ describe("safeWriteJson", () => { // Second call: tempNewFilePath -> filePath (fail) throw new Error("Primary rename failed") } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, also fail) + // Third call: backup -> filePath (rollback, also fail) throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // Should throw the original error, not the rollback error + // The original error must propagate, not the rollback error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // Verify console.error was called for the rollback failure - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to restore backup"), - expect.objectContaining({ message: "Rollback rename failed" }), - ) + // The rollback failed inside safeWriteText, so the target is gone and + // the backup is orphaned on disk. + expect(await fileExists(currentTestFilePath)).toBe(false) + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(true) consoleErrorSpy.mockRestore() }) @@ -542,4 +546,53 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual({ c: 3 }) }) + + // The commit rename targets the symlink referent. The staged temp file must + // therefore be created beside the RESOLVED target — staging beside the link + // would make the commit rename fail with EXDEV when the referent is on + // another filesystem. (Real symlinks are unavailable in this CI lane, so the + // resolution is simulated by mocking fs.realpath the same way.) + test("stages the temp file beside the symlink referent and commits onto it", async () => { + const referentDir = path.join(tempDir, "referent") + const linkDir = path.join(tempDir, "link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "test-file.json") + const referentPath = path.join(referentDir, "test-file.json") + // Seed the RESOLVED referent with real content (via the actual fs) so the + // write exercises replacement of an EXISTING referent: the lock is + // acquired on the caller path (realpath:false, which may be absent) while + // the backup + commit happen on the referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + await safeWriteJson(callerPath, { after: true }) + + // the temp file was created next to the resolved referent, NOT beside the link + const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + + // the content was committed onto the referent + expect(await readFileContent(referentPath)).toEqual({ after: true }) + }) + + // CWE-732 regression: safeWriteJson stages the temp itself and passes it + // via tempPath, so safeWriteText must apply the existing target's mode to + // the staged temp before the atomic rename — otherwise a 0o600 target is + // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + test.skipIf(process.platform === "win32")( + "preserves a restrictive 0o600 target mode through the atomic publish", + async () => { + await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify({ before: true })) + fsSyncActual.chmodSync(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { after: true }) + + expect(fsSyncActual.statSync(currentTestFilePath).mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) + }, + ) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 957a0bb20f..26af906b43 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,8 @@ import * as path from "path" import * as lockfile from "proper-lockfile" import { JsonStreamStringify } from "json-stream-stringify" +import { resolvePublishTarget, safeWriteText, type SafeWriteTextOptions } from "../services/file-safety/safeWriteText" + /** * Options for safeWriteJson function */ @@ -31,7 +33,7 @@ export interface SafeWriteJsonOptions { * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. - * - Writes to a temporary file first. + * - Writes to a temporary file first via JsonStreamStringify streaming. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. @@ -41,7 +43,6 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ - async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -51,10 +52,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Ensure directory structure exists with improved reliability try { - // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: any) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) @@ -84,13 +82,11 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) - // Propagate the lock acquisition error throw lockError } - // Variables to hold the actual paths of temp files if they are created. + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null - let actualTempBackupFilePath: string | null = null try { // If a merge callback was provided, read the current file under the lock @@ -110,79 +106,43 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file. + // Step 1: Write data to a new temporary file via JSON streaming. + // Stage it beside the *resolved* target (the symlink referent when the path is + // a symlink): safeWriteText commits by renaming onto that referent, and a + // rename across filesystems would fail with EXDEV. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) actualTempNewFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + path.dirname(resolvedTargetPath), + ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Check if the target file exists. If so, rename it to a backup path. - try { - // Check for target file existence - await fs.access(absoluteFilePath) - // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, - ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) - } catch (accessError: any) { - // Explicitly type accessError - if (accessError.code !== "ENOENT") { - // An error other than "file not found" occurred during access check. - throw accessError - } - // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. + // Step 2: Delegate backup + commit + rollback to safeWriteText with the + // pre-written temp path. backup:true keeps the old safeWriteJson + // semantics (target -> backup before commit, rollback on failure) and + // keeps the target in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before its own backup rename and + // restores it onto the directory after the commit rename). + const textOptions: SafeWriteTextOptions = { + tempPath: actualTempNewFilePath, + backup: true, } - // Step 3: Rename the new temporary file to the target file path. - // This is the main "commit" step. - await fs.rename(actualTempNewFilePath, absoluteFilePath) + await safeWriteText(absoluteFilePath, "", textOptions) - // If we reach here, the new file is successfully in place. - // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". - // Mark as "used" or "committed" + // If we reach here, the new file is successfully in place and any + // backup has already been handled by safeWriteText. actualTempNewFilePath = null - - // Step 4: If a backup was created, attempt to delete it. - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - // Mark backup as handled - actualTempBackupFilePath = null - } catch (unlinkBackupError) { - // Log this error, but do not re-throw. The main operation was successful. - // actualTempBackupFilePath remains set, indicating an orphaned backup. - console.error( - `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, - unlinkBackupError, - ) - } - } } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath - const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { - try { - await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) - // Mark as handled, prevent later unlink of this path - actualTempBackupFilePath = null - } catch (rollbackError) { - // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch - console.error( - `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, - rollbackError, - ) - } - } - // Cleanup the .new file if it exists + // A failed safeWriteText already rolled the backup (if any) back to + // the target path. Clean up the .new file if it still exists + // (safeWriteText also cleans up its tempPath on failure; this is a + // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) @@ -194,26 +154,12 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso } } - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) } } From 991ab693526f1d9527fe8278a2e06f68b6575508 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 17:42:34 +0800 Subject: [PATCH 16/46] feat(editor): async post-save diagnostics on chat-diff save path (L1, #1375) --- src/eslint-suppressions.json | 2 +- src/integrations/editor/DiffViewProvider.ts | 95 ++++++--- .../editor/__tests__/DiffViewProvider.spec.ts | 196 +++++++++++++++++- 3 files changed, 255 insertions(+), 38 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 77680449be..aacb2ce602 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1171,7 +1171,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 36f5323f19..386de8883c 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1152,8 +1152,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) @@ -1176,23 +1179,64 @@ 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) + } - try { - await delay(safeDelayMs) - } catch (error) { - console.warn(`Failed to apply write delay: ${error}`) - } + // Store the results for formatFileWriteResponse + this.newProblemsMessage = undefined + this.userEdits = undefined + this.relPath = relPath + this.newContent = content - const postDiagnostics = vscode.languages.getDiagnostics() + 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. + private async emitPostSaveDiagnostics( + relPath: string, + writeDelayMs: number, + preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][], + inMemoryDocument = false, + ): Promise { + 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. delay() never rejects, so no catch is required here. + const safeDelayMs = Math.max(0, writeDelayMs) + (inMemoryDocument ? 100 : 0) + await delay(safeDelayMs) + + // 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() @@ -1201,27 +1245,20 @@ 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}` : "" - } - - // Store the results for formatFileWriteResponse - this.newProblemsMessage = newProblemsMessage - this.userEdits = undefined - this.relPath = relPath - this.newContent = content - - return { - newProblemsMessage, - userEdits: undefined, - finalContent: content, + 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}`) } } } diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 511f0e7f3c..05c65639dd 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -33,9 +33,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 @@ -174,6 +179,8 @@ describe("DiffViewProvider", () => { }), }), }, + // L1: saveDirectly's fire-and-forget diagnostics tail emits via say(). + say: vi.fn().mockResolvedValue(true), } diffViewProvider = new DiffViewProvider(mockCwd, mockTask) @@ -811,12 +818,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") }) @@ -847,6 +859,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 () => { @@ -855,6 +871,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) }) @@ -862,11 +881,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 3dd8700c58a1855a54097c1c454161068d73e548 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 10:40:53 +0800 Subject: [PATCH 17/46] feat(file-safety): add version token for the guarded-write path (A1, #1375) Introduces the version token - dev:ino:size:mtimeNs:ctimeNs derived from a single fs.stat - a pure function of a file's on-disk state that every process computing from the same state agrees on. The compare-and-swap write guard (A2/A3) will compare the token observed at read time against the token recomputed before a write to detect stale or replaced files. No production callers yet: this is infrastructure for the file-write safety series (plan: easonLiangWorldedtech/Zoo-Code#33), part of upstream epic #1375. --- src/utils/__tests__/versionToken.spec.ts | 103 +++++++++++++++++++++++ src/utils/versionToken.ts | 57 +++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/utils/__tests__/versionToken.spec.ts create mode 100644 src/utils/versionToken.ts diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts new file mode 100644 index 0000000000..56ce269244 --- /dev/null +++ b/src/utils/__tests__/versionToken.spec.ts @@ -0,0 +1,103 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { Stats } from "fs" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { computeVersionToken, versionTokenOfStat } from "../versionToken" + +// Stats is a class-backed interface without a public constructor, so a plain-object +// test double is the only practical way to pin the token format without real files. +// Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): Stats { + const base: Partial = { + dev: 7, + ino: 4242, + size: 1234, + atimeMs: 1_700_000_000_000, + mtimeMs: 1_700_000_000_123.456, + ctimeMs: 1_700_000_000_789.999, + birthtimeMs: 1_700_000_000_000, + } + return { ...base, ...overrides } as unknown as Stats +} + +describe("versionTokenOfStat (A1, epic #1375)", () => { + it("is deterministic for an identical stat", () => { + expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) + }) + + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { + const expected = [ + "7", + "4242", + "1234", + Math.round(1_700_000_000_123.456 * 1e6).toString(), + Math.round(1_700_000_000_789.999 * 1e6).toString(), + ].join(":") + expect(versionTokenOfStat(makeStats())).toBe(expected) + }) + + it("distinguishes size changes at identical timestamps", () => { + expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes mtime changes at identical size", () => { + expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("distinguishes a replaced file (dev/ino change) with identical content state", () => { + const replaced = makeStats({ dev: 8, ino: 999 }) + expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) + }) + + it("preserves sub-ms mtime resolution in the ns field", () => { + const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) + const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) + expect(halfMsLater).not.toBe(wholeMs) + // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at + // this epoch), so allow a bounded drift instead of asserting an exact value. + const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) + expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + }) + + it("handles sizes beyond 32 bits without precision loss", () => { + const size = 5_000_000_000 // > 2^32 + const token = versionTokenOfStat(makeStats({ size })) + expect(token).toContain(`:4242:${size}:`) + }) +}) + +describe("computeVersionToken (A1, epic #1375)", () => { + let tmpDir: string + let file: string + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "version-token-")) + file = path.join(tmpDir, "seed.txt") + await fs.writeFile(file, "seed content", "utf8") + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it("derives the token from the on-disk state (single stat)", async () => { + const token = await computeVersionToken(file) + expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + }) + + it("changes when the file content changes", async () => { + const before = await computeVersionToken(file) + // Different size + a new mtime — both must move the token. + await fs.writeFile(file, "seed content, extended", "utf8") + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(await computeVersionToken(file)).not.toBe(before) + }) + + it("rejects with ENOENT for an absent file", async () => { + await expect(computeVersionToken(path.join(tmpDir, "absent.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }) + }) +}) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts new file mode 100644 index 0000000000..ee8e9e82f1 --- /dev/null +++ b/src/utils/versionToken.ts @@ -0,0 +1,57 @@ +import { stat } from "fs/promises" +import type { Stats } from "fs" + +/** + * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). + * + * A token is a pure function of a file's on-disk state, derived from a single + * `fs.stat`, so every process that observes the same file state (a second VS Code + * window, the CLI, the user's own editor tooling) computes the same token. The + * downstream guard phases (A2/A3) compare the token observed at read time with the + * token recomputed just before a write to detect "the file changed since the read" + * (stale) or "the file was replaced by a different file" (dev/ino change). + * + * Format: `dev:ino:size:mtimeNs:ctimeNs` + * + * Resolution note: Node exposes modification/change times as float milliseconds, + * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double + * conversion is correctly rounded, so the derivation is deterministic across + * processes, but it is quantized by double precision (~256 ns at the current epoch). + * Two file states whose timestamps differ by less than the quantum derive the same + * ns field; in practice distinct states differ by at least the OS clock resolution + * (and no write workload produces mtimes closer than that), so the guard contract + * holds: same disk state → same token; changed state → a different token in all + * realistic cases. dev, ino and size are exact integers, so any size or file + * identity change is always detected regardless of the timestamp quantum. + */ + +/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ +function nsFromMs(ms: number): string { + return Math.round(ms * 1e6).toString() +} + +/** + * Build the version token from an already-fetched `Stats` — no I/O. + * + * Exported separately from {@link computeVersionToken} so tests can pin the exact + * format against synthetic stats. + */ +export function versionTokenOfStat(stats: Stats): string { + return [ + stats.dev.toString(), + stats.ino.toString(), + stats.size.toString(), + nsFromMs(stats.mtimeMs), + nsFromMs(stats.ctimeMs), + ].join(":") +} + +/** + * Compute the version token for a file (one `fs.stat`). + * + * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; + * how an unobservable target is treated is decided by the guard layer (A3). + */ +export async function computeVersionToken(filePath: string): Promise { + return versionTokenOfStat(await stat(filePath)) +} From ba1332e492a8dd58dadeff4aec956daba4fc6921 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:03:05 +0800 Subject: [PATCH 18/46] docs(file-safety): correct ino precision bounds in version token (A1, #1375) Review finding: 'ino is an exact integer' was overstated. Node exposes ino as a float64 number: exact for small POSIX inode numbers, but on modern Windows the file ID exceeds 2^53 so Node's own value is already rounded (verified on node v25: non-zero ino, isSafeInteger=false). It remains deterministic per file (same file -> same token), so the token contract is unchanged; change detection rests on exact dev/size plus the mtime/ctime ns fields. Document the bound instead of claiming exactness. --- src/utils/versionToken.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index ee8e9e82f1..59a8b348fe 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -21,8 +21,15 @@ import type { Stats } from "fs" * ns field; in practice distinct states differ by at least the OS clock resolution * (and no write workload produces mtimes closer than that), so the guard contract * holds: same disk state → same token; changed state → a different token in all - * realistic cases. dev, ino and size are exact integers, so any size or file - * identity change is always detected regardless of the timestamp quantum. + * realistic cases. `dev` and `size` are exact integers. `ino` is Node's + * `number` (float64): exact for small POSIX inode numbers, but on modern Windows + * the underlying file ID exceeds 2^53, so Node's own value is already rounded — + * still deterministic per file (same file → same token), but not guaranteed + * injective across distinct files. Change detection therefore rests on size + + * mtime/ctime: any size change is always detected regardless of the timestamp + * quantum, and a replacement whose size and timestamps are indistinguishable is + * undetectable by any scheme reading the same Stats — the detect-and-reread + * stance (no lockfile) accepts that. */ /** Derive an ns-scale field from Node's float milliseconds (see module docs). */ From 68130133eda346d9910ea3a6ad6aaa41090b61df Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 11:27:00 +0800 Subject: [PATCH 19/46] fix(file-safety): derive the version token from exact BigInt stats (A1, #1375) CodeRabbit finding on this PR: the default numeric fs.stat() loses precision (values above 2^53 are rounded, including Windows file IDs) and the ms->ns derivation introduced a double-precision quantum. Fixed by fetching the stat with { bigint: true }: all five token fields (dev, ino, size, mtimeNs, ctimeNs) are exact BigInt values rendered as decimal strings, with no float anywhere. The sub-ms test now asserts an exact 1_000 ns delta instead of bounded drift, and a regression test pins a size of 10^16+1 (> Number.MAX_SAFE_INTEGER). --- src/utils/__tests__/versionToken.spec.ts | 82 ++++++++++++------------ src/utils/versionToken.ts | 62 +++++++----------- 2 files changed, 65 insertions(+), 79 deletions(-) diff --git a/src/utils/__tests__/versionToken.spec.ts b/src/utils/__tests__/versionToken.spec.ts index 56ce269244..3e2ca26b5c 100644 --- a/src/utils/__tests__/versionToken.spec.ts +++ b/src/utils/__tests__/versionToken.spec.ts @@ -1,25 +1,33 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { computeVersionToken, versionTokenOfStat } from "../versionToken" -// Stats is a class-backed interface without a public constructor, so a plain-object -// test double is the only practical way to pin the token format without real files. -// Last-resort double assertion (test-local, per AGENTS.md). -function makeStats(overrides: Partial = {}): Stats { - const base: Partial = { - dev: 7, - ino: 4242, - size: 1234, - atimeMs: 1_700_000_000_000, - mtimeMs: 1_700_000_000_123.456, - ctimeMs: 1_700_000_000_789.999, - birthtimeMs: 1_700_000_000_000, +// BigIntStats is a class-backed interface without a public constructor, so a +// plain-object test double is the only practical way to pin the token format +// without real files. Last-resort double assertion (test-local, per AGENTS.md). +function makeStats(overrides: Partial = {}): BigIntStats { + // This repo's @types/node models every StatsBase field (including the *Ms + // fields) as the parameter type T, so all values here are bigint literals; + // the token only reads the *Ns fields. Single-step downcast from Partial to + // the full type (BigIntStats has no public constructor). + const base: Partial = { + dev: 7n, + ino: 4242n, + size: 1234n, + atimeMs: 1_700_000_000_000n, + mtimeMs: 1_700_000_000_123n, + ctimeMs: 1_700_000_000_789n, + birthtimeMs: 1_700_000_000_000n, + atimeNs: 1_700_000_000_000_000_000n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + birthtimeNs: 1_700_000_000_000_000_000n, } - return { ...base, ...overrides } as unknown as Stats + return { ...base, ...overrides } as BigIntStats } describe("versionTokenOfStat (A1, epic #1375)", () => { @@ -27,44 +35,38 @@ describe("versionTokenOfStat (A1, epic #1375)", () => { expect(versionTokenOfStat(makeStats())).toBe(versionTokenOfStat(makeStats())) }) - it("matches the documented dev:ino:size:mtimeNs:ctimeNs format", () => { - const expected = [ - "7", - "4242", - "1234", - Math.round(1_700_000_000_123.456 * 1e6).toString(), - Math.round(1_700_000_000_789.999 * 1e6).toString(), - ].join(":") - expect(versionTokenOfStat(makeStats())).toBe(expected) + it("matches the documented dev:ino:size:mtimeNs:ctimeNs format with exact decimal fields", () => { + expect(versionTokenOfStat(makeStats())).toBe("7:4242:1234:1700000000123456789:1700000000789999999") }) it("distinguishes size changes at identical timestamps", () => { - expect(versionTokenOfStat(makeStats({ size: 1235 }))).not.toBe(versionTokenOfStat(makeStats())) + expect(versionTokenOfStat(makeStats({ size: 1235n }))).not.toBe(versionTokenOfStat(makeStats())) }) - it("distinguishes mtime changes at identical size", () => { - expect(versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_124 }))).not.toBe(versionTokenOfStat(makeStats())) + it("distinguishes a one-nanosecond mtime change", () => { + expect(versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_456_790n }))).not.toBe( + versionTokenOfStat(makeStats()), + ) }) it("distinguishes a replaced file (dev/ino change) with identical content state", () => { - const replaced = makeStats({ dev: 8, ino: 999 }) + const replaced = makeStats({ dev: 8n, ino: 999n }) expect(versionTokenOfStat(replaced)).not.toBe(versionTokenOfStat(makeStats())) }) - it("preserves sub-ms mtime resolution in the ns field", () => { - const wholeMs = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123 })) - const halfMsLater = versionTokenOfStat(makeStats({ mtimeMs: 1_700_000_000_123.5 })) - expect(halfMsLater).not.toBe(wholeMs) - // 0.5 ms = 500_000 ns. The float-derived ns field is quantized (~256 ns at - // this epoch), so allow a bounded drift instead of asserting an exact value. - const diff = Number(halfMsLater.split(":")[3]) - Number(wholeMs.split(":")[3]) - expect(Math.abs(diff - 500_000)).toBeLessThanOrEqual(512) + it("renders nanosecond resolution exactly (no float quantization)", () => { + const base = versionTokenOfStat(makeStats()) + const plusOneMicrosecond = versionTokenOfStat(makeStats({ mtimeNs: 1_700_000_000_123_457_789n })) + // 1_000 ns apart — the BigInt derivation must keep the delta exact. + const baseNs = BigInt(base.split(":")[3]) + const microNs = BigInt(plusOneMicrosecond.split(":")[3]) + expect(microNs - baseNs).toBe(1_000n) }) - it("handles sizes beyond 32 bits without precision loss", () => { - const size = 5_000_000_000 // > 2^32 + it("handles sizes beyond Number.MAX_SAFE_INTEGER without precision loss", () => { + const size = 10_000_000_000_000_001n // 10^16 + 1 > 2^53 const token = versionTokenOfStat(makeStats({ size })) - expect(token).toContain(`:4242:${size}:`) + expect(token).toBe(`7:4242:${size}:1700000000123456789:1700000000789999999`) }) }) @@ -82,9 +84,9 @@ describe("computeVersionToken (A1, epic #1375)", () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - it("derives the token from the on-disk state (single stat)", async () => { + it("derives the token from the on-disk state (single bigint stat)", async () => { const token = await computeVersionToken(file) - expect(token).toBe(versionTokenOfStat(await fs.stat(file))) + expect(token).toBe(versionTokenOfStat(await fs.stat(file, { bigint: true }))) }) it("changes when the file content changes", async () => { diff --git a/src/utils/versionToken.ts b/src/utils/versionToken.ts index 59a8b348fe..1738280087 100644 --- a/src/utils/versionToken.ts +++ b/src/utils/versionToken.ts @@ -1,64 +1,48 @@ import { stat } from "fs/promises" -import type { Stats } from "fs" +import type { BigIntStats } from "fs" /** * Version token for the compare-and-swap write guard (upstream epic #1375, phase A1). * * A token is a pure function of a file's on-disk state, derived from a single - * `fs.stat`, so every process that observes the same file state (a second VS Code - * window, the CLI, the user's own editor tooling) computes the same token. The - * downstream guard phases (A2/A3) compare the token observed at read time with the - * token recomputed just before a write to detect "the file changed since the read" - * (stale) or "the file was replaced by a different file" (dev/ino change). + * `fs.stat(path, { bigint: true })`, so every process that observes the same file + * state (a second VS Code window, the CLI, the user's own editor tooling) computes + * the same token. The downstream guard phases (A2/A3) compare the token observed at + * read time with the token recomputed just before a write to detect "the file + * changed since the read" (stale) or "the file was replaced by a different file" + * (dev/ino change). * * Format: `dev:ino:size:mtimeNs:ctimeNs` * - * Resolution note: Node exposes modification/change times as float milliseconds, - * so the ns fields are derived as `Math.round(mtimeMs * 1e6)`. The integer-to-double - * conversion is correctly rounded, so the derivation is deterministic across - * processes, but it is quantized by double precision (~256 ns at the current epoch). - * Two file states whose timestamps differ by less than the quantum derive the same - * ns field; in practice distinct states differ by at least the OS clock resolution - * (and no write workload produces mtimes closer than that), so the guard contract - * holds: same disk state → same token; changed state → a different token in all - * realistic cases. `dev` and `size` are exact integers. `ino` is Node's - * `number` (float64): exact for small POSIX inode numbers, but on modern Windows - * the underlying file ID exceeds 2^53, so Node's own value is already rounded — - * still deterministic per file (same file → same token), but not guaranteed - * injective across distinct files. Change detection therefore rests on size + - * mtime/ctime: any size change is always detected regardless of the timestamp - * quantum, and a replacement whose size and timestamps are indistinguishable is - * undetectable by any scheme reading the same Stats — the detect-and-reread - * stance (no lockfile) accepts that. + * Precision: the stat is fetched in `bigint` mode, so all five fields are exact + * `BigInt` values rendered as decimal strings — no float is involved anywhere. + * There is therefore no precision loss for large sizes or inodes (a Windows file ID + * exceeds 2^53 and is still exact), and the ns timestamps are the kernel's exact + * nanosecond values rather than a ms→ns derivation (no ~256 ns double-precision + * quantum). Guarantee: same disk state → same token, deterministic across + * processes; any change to size, file identity, or mtime/ctime → a different token. + * + * Platform note: on POSIX `ctime` is the last file-status change; on Windows it is + * the file creation time. The token only requires it to move when the file's + * metadata is replaced, which holds on both. */ -/** Derive an ns-scale field from Node's float milliseconds (see module docs). */ -function nsFromMs(ms: number): string { - return Math.round(ms * 1e6).toString() -} - /** - * Build the version token from an already-fetched `Stats` — no I/O. + * Build the version token from an already-fetched `BigIntStats` — no I/O. * * Exported separately from {@link computeVersionToken} so tests can pin the exact * format against synthetic stats. */ -export function versionTokenOfStat(stats: Stats): string { - return [ - stats.dev.toString(), - stats.ino.toString(), - stats.size.toString(), - nsFromMs(stats.mtimeMs), - nsFromMs(stats.ctimeMs), - ].join(":") +export function versionTokenOfStat(stats: BigIntStats): string { + return [stats.dev, stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => value.toString()).join(":") } /** - * Compute the version token for a file (one `fs.stat`). + * Compute the version token for a file (one `fs.stat` in bigint mode). * * Rejects with the underlying ENOENT (or equivalent) error when the file is absent; * how an unobservable target is treated is decided by the guard layer (A3). */ export async function computeVersionToken(filePath: string): Promise { - return versionTokenOfStat(await stat(filePath)) + return versionTokenOfStat(await stat(filePath, { bigint: true })) } From 588b95fdc2420feb9163b0d21dd1d1f0bbfe7bf9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 14:52:58 +0800 Subject: [PATCH 20/46] feat(task): per-task file observation registry (A2, #1375) --- src/core/task/Task.ts | 2 + .../__tests__/observationRegistry.spec.ts | 72 +++++++++++ src/core/task/observationRegistry.ts | 47 ++++++++ src/core/tools/ReadFileTool.ts | 12 ++ src/core/tools/__tests__/readFileTool.spec.ts | 113 ++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/core/task/__tests__/observationRegistry.spec.ts create mode 100644 src/core/task/observationRegistry.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..8977b60830 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -103,6 +103,7 @@ import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" +import { ObservationRegistry } from "./observationRegistry" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" @@ -181,6 +182,7 @@ export class Task extends EventEmitter implements TaskLike { readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string + readonly observationRegistry = new ObservationRegistry() /** * The mode associated with this task. Persisted across sessions diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..51b73aabde --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest" + +import { ObservationRegistry } from "../observationRegistry" + +describe("ObservationRegistry", () => { + it("observe → get returns the recorded version and observedAt", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") + + const obs = reg.get("/a/b/c.ts") + expect(obs).toBeDefined() + expect(obs!.version).toBe("1:2:300:4000000000:5000000000") + expect(typeof obs!.observedAt).toBe("number") + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + const first = reg.get("/a/b/c.ts")! + expect(first.version).toBe("v1") + + vi.advanceTimersByTime(50) + reg.observe("/a/b/c.ts", "v2") + const second = reg.get("/a/b/c.ts")! + expect(second.version).toBe("v2") + expect(second.observedAt).toBeGreaterThan(first.observedAt) + + vi.useRealTimers() + }) + + it("has returns true for observed paths, false otherwise", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + expect(reg.has("/x.ts")).toBe(true) + expect(reg.has("/y.ts")).toBe(false) + }) + + it("size reflects the number of observed entries", () => { + const reg = new ObservationRegistry() + expect(reg.size).toBe(0) + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + expect(reg.size).toBe(2) + }) + + it("clear removes all entries and resets size to 0", () => { + const reg = new ObservationRegistry() + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + reg.clear() + expect(reg.size).toBe(0) + expect(reg.get("/a.ts")).toBeUndefined() + expect(reg.has("/b.ts")).toBe(false) + }) + + it("get on empty registry returns undefined", () => { + const reg = new ObservationRegistry() + expect(reg.get("/any.ts")).toBeUndefined() + }) + + it("separate instances are independent — observing in one does not appear in the other", () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")).toBeDefined() + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..871f80225b --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,47 @@ +/** + * Per-task file observation registry (upstream epic #1375, phase A2). + * + * Each Task owns its own instance so parent and subtask observations are + * independent. The S4 guarded-write will compare these versions against the + * token recomputed pre-write to detect stale reads or file replacement. + * + * Pure in-memory — zero I/O, no dependencies. No behavior change in this PR: + * observations are recorded but not consulted. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number +} + +export class ObservationRegistry { + private readonly entries = new Map() + + /** + * Record an observation for a file at its absolute path. + * + * Re-observing replaces the entry with a fresh observedAt timestamp and + * the new version token. + */ + observe(absolutePath: string, version: string): void { + this.entries.set(absolutePath, { version, observedAt: Date.now() }) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } + + has(absolutePath: string): boolean { + return this.entries.has(absolutePath) + } + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } +} diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..6e222e1309 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,6 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { computeVersionToken } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -220,6 +221,11 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) + updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, }) @@ -799,6 +805,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + // A2 (plan #33 / epic #1375): mirror the native path — record the observed + // on-disk version so legacy-format reads also feed the future write guard. + // A stat failure leaves the target unobserved and never fails the read. + const version = await computeVersionToken(fullPath).catch(() => undefined) + if (version) task.observationRegistry.observe(fullPath, version) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..6108e78151 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -1489,5 +1499,108 @@ describe("ReadFileTool", () => { expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + + describe("observation registry", () => { + it("records an observation on successful read of an existing file", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Override the beforeEach default stat mock with proper BigIntStats. + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + // Spy on observe to capture the exact key used (Windows path.resolve may use backslashes). + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // Verify the tool called observe exactly once with a valid token. + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("existing.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Verify get() returns the same data using the spy-captured key. + const obs = reg.get(calledPath) + expect(obs).toBeDefined() + expect(obs!.version).toBe(calledVersion) + }) + + it("a failed read (absent path) leaves the registry size 0 and does not throw", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "missing.ts" }, mockTask as unknown as Task, callbacks) + + // observationRegistry is guaranteed present because we passed it in createMockTask. + const reg = mockTask.observationRegistry + expect(reg).toBeDefined() + expect(reg!.size).toBe(0) + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("legacy.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("two separate Task-owned registries are independent", async () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + }) }) }) From 7a25fc076d2c9f0d488c21bba312dac8dd0bdb95 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 22:09:22 +0800 Subject: [PATCH 21/46] feat(tools): guarded write CAS core with per-path FIFO chain (S4a, #1375) --- src/core/tools/ReadFileTool.ts | 36 +- src/core/tools/__tests__/guardedWrite.spec.ts | 454 ++++++++++++++++++ src/core/tools/__tests__/readFileTool.spec.ts | 211 +++++++- src/core/tools/guardedWrite.ts | 260 ++++++++++ src/eslint-suppressions.json | 2 +- src/utils/__tests__/safeWriteJson.test.ts | 77 +++ src/utils/safeWriteJson.ts | 31 +- 7 files changed, 1051 insertions(+), 20 deletions(-) create mode 100644 src/core/tools/__tests__/guardedWrite.spec.ts create mode 100644 src/core/tools/guardedWrite.ts diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 6e222e1309..3647c631ee 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,7 +16,7 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" -import { computeVersionToken } from "../../utils/versionToken" +import { versionTokenOfStat } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -215,6 +215,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read text file content with lossy UTF-8 conversion // Reading as Buffer first allows graceful handling of non-UTF8 bytes // (they become U+FFFD replacement characters instead of throwing) + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") const result = this.processTextFile(fileContent, entry) @@ -222,9 +225,18 @@ export class ReadFileTool extends BaseTool<"read_file"> { await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. - // A stat failure leaves the target unobserved and never fails the read. - const version = await computeVersionToken(fullPath).catch(() => undefined) - if (version) task.observationRegistry.observe(fullPath, version) + // The token is captured before AND after the read; the target is observed only + // when both match — a mutation between the two stats means the content the model + // received is not the on-disk state, and observing it would let a later write + // match a token the model never saw. A stat failure leaves the target + // unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken) + } + } updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, @@ -774,6 +786,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const rawContent = await fs.readFile(fullPath, "utf8") // Handle line ranges if specified @@ -808,9 +823,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // A2 (plan #33 / epic #1375): mirror the native path — record the observed // on-disk version so legacy-format reads also feed the future write guard. - // A stat failure leaves the target unobserved and never fails the read. - const version = await computeVersionToken(fullPath).catch(() => undefined) - if (version) task.observationRegistry.observe(fullPath, version) + // Observe only when the pre-read and post-read tokens match (a mutation between + // them means the returned content is not the on-disk state). A stat failure + // leaves the target unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken) + } + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..122c1c6d9b --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,454 @@ +/** + * Tests for the guarded-write compare-and-swap core (upstream epic #1375, + * phase A4a). + * + * Covers guard selection through the S2 observation registry, version-token + * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO + * ordering, exactly-one winner under concurrency, no wedge after a rejected + * link, and independence across paths. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain } from "../guardedWrite" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../../utils/versionToken" +import { ObservationRegistry } from "../../task/observationRegistry" +import type { Task } from "../../task/Task" + +// -- Mocks ------------------------------------------------------------------- + +vi.mock("fs/promises", () => ({ + access: vi.fn(), + stat: vi.fn(), +})) + +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn(), +})) + +const mockedFsAccess = vi.mocked(fs.access) +const mockedComputeVersionToken = vi.mocked(computeVersionToken) +const mockedSafeWriteText = vi.mocked(safeWriteText) + +// -- Fixtures ---------------------------------------------------------------- + +const WORKSPACE = "/test/workspace" + +/** Resolve a fixture path the same way guardedWrite resolves task.cwd-relative paths. */ +const abs = (relPath: string): string => path.resolve(WORKSPACE, relPath) + +interface MockTaskOptions { + cwd?: string + observationRegistry?: ObservationRegistry +} + +/** + * Minimal structural Task: guardedWrite only reads task.cwd and + * task.observationRegistry. The real Task constructor needs the full provider + * machinery, so a single documented double cast stands in for the class. + */ +function createMockTask(options: MockTaskOptions = {}): Task { + const task = { + cwd: options.cwd ?? WORKSPACE, + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), + } + return task as unknown as Task +} + +// -- Tests ------------------------------------------------------------------- + +describe("guardedWrite (S4a, epic #1375)", () => { + beforeEach(() => { + vi.resetAllMocks() + resetChain() + }) + + describe("unobserved create", () => { + it("succeeds when the file is absent and publishes via safeWriteText", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "create")).rejects.toThrow( + "File already exists at " + + abs("existing.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows I/O errors that are not ENOENT verbatim (no guard verdict on access failure)", async () => { + const failures = [{ code: "EACCES" }, null, "volume offline", new Error("EIO-ish failure")] + for (const failure of failures) { + mockedFsAccess.mockRejectedValueOnce(failure) + await expect(createIfAbsent(abs("io-error.txt"), "x")).rejects.toBe(failure) + } + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("deleted-after-read target", () => { + it("normalizes an ENOENT from the version token into the re-read remediation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("vanished.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + + // The file was deleted after the read: the token computation fails + // with a raw ENOENT, which the guard must convert into the standard + // re-read-then-retry contract. + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "vanished.txt", "next", "update")).rejects.toThrow( + "File was deleted after it was read", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows non-ENOENT token failures verbatim from replaceIfVersion", async () => { + const failure = { code: "EACCES" } + mockedComputeVersionToken.mockRejectedValueOnce(failure) + + await expect(replaceIfVersion(abs("locked.txt"), "v1", "next")).rejects.toBe(failure) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + describe("unobserved update", () => { + it("succeeds when the file is absent (same create guard)", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "update")).rejects.toThrow( + "File already exists at " + + abs("existing.txt") + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed create", () => { + it("recreates a file that vanished after the read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("gone.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "gone.txt", "back", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + }) + + it("goes through the version guard when the file still exists", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "kept.txt", "rewritten", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "kept.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("defers to the version guard when the access check is denied (not ENOENT)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("locked.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "EACCES" }) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "locked.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (version CAS)", () => { + it("publishes when the on-disk version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("fails with the stale remediation suffix when the version moved - nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("edit", () => { + it("fails read-first when the file was never observed - nothing published, no I/O", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "any.txt", "patched", "edit")).rejects.toThrow( + "File not read yet -- read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes when the version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v3") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "patched", "edit")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v3); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("concurrency: per-path FIFO chain", () => { + it("two concurrent updates on one path - exactly one publishes, the other fails stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("shared.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + const p1 = guardedWrite(task, "shared.txt", "first", "update") + const p2 = guardedWrite(task, "shared.txt", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(r2.reason.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + }) + + it("observed-absent then two concurrent creates - the second fails stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("absent.txt"), "v1") // read before, file later vanished + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + let publishes = 0 + mockedSafeWriteText.mockImplementation(async () => { + publishes += 1 + if (publishes === 1) { + // After the first publish the file exists again under a new token. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + } + }) + + const p1 = guardedWrite(task, "absent.txt", "first", "create") + const p2 = guardedWrite(task, "absent.txt", "second", "create") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(publishes).toBe(1) + expect(r2.reason.message).toContain("Stale version") + expect(r2.reason.message).toContain("re-read the file, then retry.") + }) + + it("the chain settles after a rejection - a later matching write still runs", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("settle.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") // already stale at v1 + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "settle.txt", "first", "update") + await expect(p1).rejects.toThrow("Stale version") + + // No resetChain: the rejected link must not wedge the chain. The + // caller re-reads the file (observation refreshed to v2) and retries. + reg.observe(abs("settle.txt"), "v2") + const p2 = guardedWrite(task, "settle.txt", "second", "update") + await expect(p2).resolves.toBeUndefined() + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + }) + + it("evicts settled chain entries - a later write still serializes in order", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("evict.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // A first write settles; its chain entry is evicted with it. + const p1 = guardedWrite(task, "evict.txt", "first", "update") + await expect(p1).resolves.toBeUndefined() + + // Two rapid writes submitted after the eviction must still run one + // at a time in submission order (the eviction must not drop the + // chain for in-flight or just-enqueued links). + const order: string[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string) => { + order.push(content) + }) + const p2 = guardedWrite(task, "evict.txt", "second", "update") + const p3 = guardedWrite(task, "evict.txt", "third", "update") + await Promise.all([p2, p3]) + + expect(order).toEqual(["second", "third"]) + // Three publishes in total: the settled first write plus the two + // serialized rapid writes. + expect(mockedSafeWriteText).toHaveBeenCalledTimes(3) + }) + + it("writes on different paths are independent (no cross-path serialization)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + reg.observe(abs("b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "a.txt", "a", "update") + const p2 = guardedWrite(task, "b.txt", "b", "update") + await Promise.all([p1, p2]) + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + }) + + describe("path resolution", () => { + it("resolves a relative path against task.cwd", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("sub/dir.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "sub/dir.txt", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + }) + + it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("sub/dir.txt") + // ReadFileTool observes under path.resolve(task.cwd, relPath) — the + // canonical spelling. A write addressed with a trailing separator used + // to bypass the observation (isAbsolute passthrough) and fail + // "File already exists" / "File not read yet" for a file that was read. + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, canonical + "/", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + }) + + it("serializes two spellings of one file through a single chain key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("shared2.txt") + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + // Plain spelling vs the trailing-separator spelling: with one chain key + // they are strictly ordered (first matches v1, second sees v2). + const p1 = guardedWrite(task, canonical, "first", "update") + const p2 = guardedWrite(task, canonical + "/", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + if (r1.status !== "fulfilled" || r2.status !== "rejected") { + throw new Error("expected exactly one publish, got " + r1.status + " / " + r2.status) + } + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(r2.reason.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + }) + }) + + describe("resetChain", () => { + it("detaches pending links so later writes start a fresh chain", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("x.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "x.txt", "a", "update") + resetChain() + await guardedWrite(task, "x.txt", "b", "update") + + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + }) + }) +}) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6108e78151..7e2fa3aac7 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -197,7 +197,18 @@ describe("ReadFileTool", () => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // The stat default carries BigIntStats fields (A2, epic #1375): reads now + // token-ize the pre/post stats, so the default must look like a real bigint stat. + // Tests overriding it do so per-call with mockResolvedValue(Once). + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -1591,6 +1602,204 @@ describe("ReadFileTool", () => { expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) }) + it("does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // A mutation lands mid-read: the post-read stat differs. + const postStats = { ...preStats, size: BigInt(301) } + + // Call order: directory check, pre-read stat, post-read stat. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce(postStats as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "mutated.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself succeeded, but the target stays unobserved: the content the + // model received is not the on-disk state, so observing it would let a later + // write match a token the model never saw. + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("leaves the target unobserved without failing the read when the pre-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + // The read still succeeds — a stat failure never fails the read. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("leaves the target unobserved without failing the read when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "post-stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("legacy format: does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Call order: directory check, pre-read stat, post-read stat (mutated). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce({ ...preStats, size: BigInt(301) } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-mutated.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("legacy format: leaves the target unobserved when a stat fails without failing the read", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + it("legacy format: leaves the target unobserved when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-post-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) it("two separate Task-owned registries are independent", async () => { const regA = new ObservationRegistry() const regB = new ObservationRegistry() diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..596cc41035 --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,260 @@ +/** + * Guarded-write compare-and-swap core (upstream epic #1375, phase A4a). + * + * Wraps the S3 safeWriteText publish primitive behind version-token guards so + * that every write is deterministic: + * + * - an unobserved target may only be created when it is absent + * (createIfAbsent); + * - an observed target is published only when the on-disk version token still + * matches the token recorded at read time (replaceIfVersion); + * - an edit-style write requires a prior observation (unobservedEditGuard). + * + * A per-absolute-path FIFO chain of tail promises orders concurrent + * in-process writes to the same path: the first matching write wins, the rest + * fail stale. Observations come from the task's S2 ObservationRegistry. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../utils/versionToken" +import type { Task } from "../task/Task" + +// -- Types ------------------------------------------------------------------ + +/** Write kind that drives guard selection. */ +export type GuardedWriteKind = "create" | "update" | "edit" + +/** Internal error thrown when a guard rejects a write. */ +class GuardRejectedError extends Error { + constructor( + message: string, + readonly path: string, + ) { + super(message) + this.name = "GuardRejectedError" + } +} + +// -- Per-path tail-promise chain -------------------------------------------- + +/** + * Per-absolute-path FIFO chain of pending guarded writes (tail promise per + * path). Every write enqueues onto the current tail for its path, so + * concurrent writes to the same path run one at a time in submission order. + * + * The chain never leaks a rejection through itself: each link settles, a + * rejected link is skipped by the next writer (a failed write must not block + * later writes to the same path), and every caller receives its own link + * promise to handle. + * + * Settled entries are evicted (below), so a long-lived extension does not + * accumulate a map entry per distinct written path. + */ +const pendingChains = new Map>() + +/** + * Enqueue a write operation on the per-path FIFO chain. + * + * Returns the promise for this link; it always settles. A prior link that + * rejected is skipped, not propagated. The map entry for this link is + * deleted once it settles — but only while it is still the current tail for + * the path, so a replacement enqueued in the meantime keeps ownership. + */ +function enqueue(pathKey: string, fn: () => Promise): Promise { + const prev = pendingChains.get(pathKey) ?? Promise.resolve() + const next = prev.then(fn, fn) + pendingChains.set(pathKey, next) + void next.then( + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + ) + return next +} + +// -- Guard primitives -------------------------------------------------------- + +/** + * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or + * undefined when the value carries none. + */ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** True when the path is absent on disk (fs.access reports ENOENT). */ +async function fileIsAbsent(absolutePath: string): Promise { + try { + await fs.access(absolutePath) + return false + } catch (error: unknown) { + return errorCode(error) === "ENOENT" + } +} + +/** + * Publish content only if the target file does not exist. + * + * Rejects with a loud remediation error when the file already exists: the + * write was issued for a file that was never read, so the caller must read + * the file first, then retry. + */ +export async function createIfAbsent(absolutePath: string, content: string): Promise { + try { + await fs.access(absolutePath) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + await safeWriteText(absolutePath, content) + return + } + + throw new GuardRejectedError( + "File already exists at " + + absolutePath + + " and was not read before this write -- read the file first, then retry.", + absolutePath, + ) +} + +/** + * Publish content only if the current on-disk version token equals + * expectedVersion (the token observed at read time). + * + * On a match the content is published via the S3 safeWriteText primitive; on + * a mismatch the write is rejected stale with a re-read-then-retry + * remediation suffix. + */ +export async function replaceIfVersion(absolutePath: string, expectedVersion: string, content: string): Promise { + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The observed file was deleted after the read: the version recorded + // at read time no longer exists on disk. Normalize the raw ENOENT + // into the guard's re-read-then-retry contract so the caller gets a + // remediation it can act on, not a raw errno. + throw new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + absolutePath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + + if (currentVersion === expectedVersion) { + await safeWriteText(absolutePath, content) + return + } + + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + absolutePath, + ) +} + +/** + * Unobserved-edit guard: an edit-style write without a prior observation is + * rejected before any I/O. The literal-match / patch logic stays with the + * tools in S4b; this guard only verifies that a read happened first. + * + * Returns Promise because the rejection is total: this function + * never resolves. + */ +export async function unobservedEditGuard(absolutePath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", absolutePath) +} + +// -- Public API -------------------------------------------------------------- + +/** + * Resolve a relative or absolute path against task.cwd. + * + * path.resolve also normalizes an already-absolute input (collapsing "." / ".." + * segments and trailing separators), so the key always matches the + * ObservationRegistry key recorded at read time (ReadFileTool observes under + * path.resolve(task.cwd, relPath)) and two spellings of one file share one + * FIFO chain. + */ +function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { + return path.resolve(task.cwd, relPathOrAbsolute) +} + +/** + * Guarded write entry point. + * + * 1. Resolves the absolute path against task.cwd. + * 2. Consults the task's S2 observation registry to pick the guard: + * - unobserved + create/update: createIfAbsent (rejects if it exists); + * - observed + create on a file that vanished after the read: recreate; + * - observed otherwise: replaceIfVersion (CAS on the S1 version token); + * - unobserved + edit: unobservedEditGuard. + * 3. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * the same path are deterministically ordered. + */ +export async function guardedWrite( + task: Task, + relPathOrAbsolute: string, + content: string, + kind: GuardedWriteKind = "update", +): Promise { + const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + + return enqueue(absolutePath, async () => { + const obs = task.observationRegistry.get(absolutePath) + + if (obs === undefined) { + // Edit-style writes require a prior read: no observation, no write. + if (kind === "edit") { + await unobservedEditGuard(absolutePath) + } + // Never read: only an absent target may be created. (The edit guard + // above rejects before reaching this line.) + await createIfAbsent(absolutePath, content) + return + } + + if (kind === "edit") { + await replaceIfVersion(absolutePath, obs.version, content) + return + } + + // kind is "create" or "update": a "create" on a file that vanished + // after the read recreates it; otherwise the version recorded at read + // time must still match the on-disk token. + if (kind === "create" && (await fileIsAbsent(absolutePath))) { + await createIfAbsent(absolutePath, content) + } else { + await replaceIfVersion(absolutePath, obs.version, content) + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + */ +export function resetChain(): void { + pendingChains.clear() +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 77680449be..5528f3b0b1 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 97 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 064207e21f..631fb9f810 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -4,6 +4,7 @@ import * as path from "path" import * as os from "os" import { safeWriteJson } from "../safeWriteJson" +import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -579,6 +580,82 @@ describe("safeWriteJson", () => { expect(await readFileContent(referentPath)).toEqual({ after: true }) }) + // proper-lockfile with realpath:false keys the lock by the given path, so a + // symlink alias and its referent must coordinate through ONE lock on the + // resolved referent — otherwise a concurrent merge through both aliases + // reads the same JSON and overwrites one update. (Real symlinks are + // unavailable in this CI lane, so the resolution is simulated by mocking + // fs.realpath, the same way as the staging test above.) + test("acquires the lock on the resolved referent, not the caller alias", async () => { + vi.resetModules() // fresh module instances so the doMock below is picked up + + const referentDir = path.join(tempDir, "lock-referent") + const linkDir = path.join(tempDir, "lock-link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "locked.json") + const referentPath = path.join(referentDir, "locked.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) + + vi.spyOn(fs, "realpath").mockResolvedValue(referentPath) + + // Wrap the real lock in a capturing mock, and drive the two rare error paths + // (the onCompromised callback and a failing release) so they stay covered + // without real lockfile staleness. The callback rethrows by design, so + // the mock swallows that throw and lets the real lock proceed. + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn( + async ( + file: Parameters[0], + options?: Parameters[1], + ) => { + try { + options?.onCompromised?.(new Error("lock compromised (test)")) + } catch { + // onCompromised rethrows by design; swallow so the real lock proceeds. + } + const release = await realLockfile.lock(file, options) + return async () => { + await release() + throw new Error("release failed (test)") + } + }, + ) + const lockMock = lockMockFn as unknown as typeof realLockfile.lock + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock, + })) + + // Re-import safeWriteJson so it picks up the mocked proper-lockfile. + const { safeWriteJson: mockedSafeWriteJson } = await import("../safeWriteJson") + + const mergeFn = vi.fn((existing: unknown, incoming: unknown) => ({ + ...(existing as Record), + ...(incoming as Record), + })) + + // Capture the compromise + release-failure logs. + const consoleErrorSpy = vi.spyOn(console, "error") + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + + // The lock was keyed by the resolved referent — every alias shares it. + expect(lockMock).toHaveBeenCalledTimes(1) + expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) + // The merge read the referent's content through that single lock. + expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // The compromise callback and the failed release were logged, not thrown. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to release lock"), + expect.any(Error), + ) + + vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + }) + // CWE-732 regression: safeWriteJson stages the temp itself and passes it // via tempPath, so safeWriteText must apply the existing target's mode to // the staged temp before the atomic rename — otherwise a 0o600 target is diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 26af906b43..a9f837fc50 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -59,12 +59,22 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso throw dirError } + // Resolve the publish target BEFORE acquiring the lock: proper-lockfile keys + // the lock by the given path (realpath is false below because the file may + // not exist yet), so a symlink alias and its referent would otherwise take + // two distinct locks for one underlying file — a concurrent merge through + // both aliases could then read the same JSON and overwrite one update. + // Locking the resolved referent coordinates every alias through one lock. + // resolvePublishTarget tolerates a not-yet-existing file (it returns the + // given path on ENOENT), preserving the previous create-from-absent flow. + const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // Acquire the lock before any file operations try { - releaseLock = await lockfile.lock(absoluteFilePath, { + releaseLock = await lockfile.lock(resolvedTargetPath, { stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long - realpath: false, // the file may not exist yet, which is acceptable + realpath: false, // resolvedTargetPath is already the referent; the file may still not exist yet, which is acceptable retries: { // Configuration for retrying lock acquisition retries: 5, // Number of retries after the initial attempt @@ -73,7 +83,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) }, onCompromised: (err) => { - console.error(`Lock at ${absoluteFilePath} was compromised:`, err) + console.error(`Lock at ${resolvedTargetPath} was compromised:`, err) throw err }, }) @@ -81,7 +91,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // If lock acquisition fails, we throw immediately. // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. - console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) + console.error(`Failed to acquire lock for ${resolvedTargetPath}:`, lockError) throw lockError } @@ -95,7 +105,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) } catch (error: unknown) { const code = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -108,9 +118,8 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso // Step 1: Write data to a new temporary file via JSON streaming. // Stage it beside the *resolved* target (the symlink referent when the path is - // a symlink): safeWriteText commits by renaming onto that referent, and a - // rename across filesystems would fail with EXDEV. - const resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + // a symlink; resolvedTargetPath above): safeWriteText commits by renaming + // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( path.dirname(resolvedTargetPath), ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", @@ -129,13 +138,13 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso backup: true, } - await safeWriteText(absoluteFilePath, "", textOptions) + await safeWriteText(resolvedTargetPath, "", textOptions) // If we reach here, the new file is successfully in place and any // backup has already been handled by safeWriteText. actualTempNewFilePath = null } catch (originalError) { - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + console.error(`Operation failed for ${resolvedTargetPath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath @@ -160,7 +169,7 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso try { await releaseLock() } catch (unlockError) { - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + console.error(`Failed to release lock for ${resolvedTargetPath}:`, unlockError) } } } From 68be2648387ebaa03ceb8c353b08eea1d0f3df98 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 23:59:02 +0800 Subject: [PATCH 22/46] feat(tools): wire guarded writes into the diff-view save paths (S4b, #1375) --- src/core/tools/ApplyDiffTool.ts | 4 +- src/core/tools/ApplyPatchTool.ts | 24 ++- src/core/tools/EditFileTool.ts | 5 +- src/core/tools/EditTool.ts | 12 +- src/core/tools/SearchReplaceTool.ts | 12 +- src/core/tools/WriteToFileTool.ts | 11 +- .../applyDiffTool.guardedWrite.spec.ts | 157 ++++++++++++++ .../__tests__/applyPatchTool.execute.spec.ts | 198 ++++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 95 +++++++++ src/core/tools/__tests__/editTool.spec.ts | 46 ++++ .../tools/__tests__/searchReplaceTool.spec.ts | 46 ++++ .../tools/__tests__/writeToFileTool.spec.ts | 73 +++++++ src/integrations/editor/DiffViewProvider.ts | 21 +- .../editor/__tests__/DiffViewProvider.spec.ts | 88 +++++++- 14 files changed, 779 insertions(+), 13 deletions(-) create mode 100644 src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd2..9e6f87f653 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -173,7 +173,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } - // Save directly without showing diff view or opening the file + // Save directly without showing diff view or opening the file. The diff is + // applied to an existing file, so edit-guard semantics require a prior read. task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent await task.diffViewProvider.saveDirectly( @@ -182,6 +183,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { false, diagnosticsEnabled, writeDelayMs, + "edit", ) } else { // Original behavior with diff view diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..8b261bdeeb 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -214,7 +214,16 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patch supplies the complete new content, so create-guard + // semantics apply (an unobserved existing target is rejected, not overwritten). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + true, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } @@ -408,12 +417,14 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save new content to the new path if (isPreventFocusDisruptionEnabled) { + // The move destination is published with the complete new content. await task.diffViewProvider.saveDirectly( change.movePath, newContent, false, diagnosticsEnabled, writeDelayMs, + "create", ) } else { // Write to new path and delete old file @@ -433,7 +444,16 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patched file content is complete, so create-guard + // semantics apply (stale observed versions are rejected with a re-read hint). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..911d4ed85b 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -436,13 +436,16 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file + // Direct file write without diff view or opening the file. In-place edits + // use edit-guard semantics (a prior read is required); new-file creation + // keeps create-guard semantics. await task.diffViewProvider.saveDirectly( relPath, newContent, isNewFile, diagnosticsEnabled, writeDelayMs, + isNewFile ? "create" : "edit", ) } else { // Call saveChanges to update the DiffViewProvider properties diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 2ae8bf4ed0..6bf93d55d9 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -211,8 +211,16 @@ export class EditTool extends BaseTool<"edit"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index e29b124010..c11d59a4fc 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -207,8 +207,16 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + ) } else { // Call saveChanges to update the DiffViewProvider properties await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..b3f1edca30 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -133,7 +133,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: this write carries the complete file content, so it uses + // create-guard semantics (unobserved targets may only be created when absent). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + ) } else { if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts new file mode 100644 index 0000000000..2181c98941 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -0,0 +1,157 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Error: ${msg}`), + rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff: string) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "api" + | "diffStrategy" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + api: { + getModel: () => ({ id: "claude-sonnet-4-5" }), + } as unknown as Task["api"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }), + } as unknown as Task["diffStrategy"], + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("publishes through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) +}) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..2dcf201f04 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -94,3 +94,201 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).not.toHaveBeenCalled() }) }) + +describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + const updatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++new line one ++new line two +*** End Patch` + + const movePatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++modified file content +*** End Patch` + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("update: publishes through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "create", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "new line one\nnew line two\n", + true, + true, + 1000, + "create", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: publishes the destination through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + ) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("update: surfaces the unobserved-existing remediation as a tool error", async () => { + const guardError = new Error( + "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("update: surfaces the stale-version remediation as a tool error", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) +}) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1ff8d52a8d..4606b51ff6 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -168,6 +168,7 @@ describe("editFileTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -178,6 +179,13 @@ describe("editFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -687,6 +695,93 @@ describe("editFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes an existing-file edit through saveDirectly with edit kind", async () => { + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + expect(result).toContain("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("publishes new-file creation through saveDirectly with create kind", async () => { + await executeEditFileTool( + { old_string: "", new_string: "New file content" }, + { fileExists: false, experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "New file content", + true, + true, + 1000, + "create", + ) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("still fails a literal mismatch with the existing message before the guard runs", async () => { + const result = await executeEditFileTool( + { old_string: "NonExistent", new_string: "Whatever" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(result).toContain("No match found") + expect(result).toContain("") + expect(mockTask.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + }) + describe("CRLF normalization", () => { it("preserves CRLF line endings on output", async () => { const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index a5f665b9e5..59906771b1 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -169,6 +169,7 @@ describe("editTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -179,6 +180,13 @@ describe("editTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const defaultParams = { file_path: testFilePath, @@ -424,4 +432,42 @@ describe("editTool", () => { expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockTask.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 5cf10790d4..2a91ec1a65 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -166,6 +166,7 @@ describe("searchReplaceTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -176,6 +177,13 @@ describe("searchReplaceTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -439,4 +447,42 @@ describe("searchReplaceTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + ) + expect(mockCline.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..930bc1661c 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -26,6 +26,13 @@ vi.mock("delay", () => ({ default: vi.fn(), })) +// The focus-disruption save path reads the original file content via fs.readFile. +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original content"), + }, +})) + vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false), createDirectoriesForFile: vi.fn().mockResolvedValue([]), @@ -156,6 +163,11 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "final content", + }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -187,6 +199,7 @@ describe("writeToFileTool", () => { mockCline.say = vi.fn().mockResolvedValue(undefined) mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() + mockCline.processQueuedMessages = vi.fn() mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) @@ -204,6 +217,7 @@ describe("writeToFileTool", () => { fileExists?: boolean isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { // Configure mocks based on test scenario @@ -213,6 +227,13 @@ describe("writeToFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) // Create a tool use object const toolUse: ToolUse = { @@ -450,6 +471,58 @@ describe("writeToFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with create kind when the write is approved", async () => { + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + "create", + ) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") + expect(mockCline.didEditFile).toBe(true) + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + `File already exists at ${absoluteFilePath} and was not read before this write -- read the file first, then retry.`, + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) + describe("error handling", () => { it("handles general file operation errors", async () => { mockCline.diffViewProvider.open.mockRejectedValue(new Error("General error")) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 36f5323f19..51bce14cc7 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,7 +18,7 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" -import { safeWriteText } from "../../services/file-safety/safeWriteText" +import { guardedWrite, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -1137,6 +1137,10 @@ export class DiffViewProvider { * @param relPath - Relative path to the file * @param content - Content to write to the file * @param openFile - Whether to show the file in editor (false = open in memory only for diagnostics) + * @param writeKind - Guarded-write kind that selects the S4a guard for this publish. + * Defaults to "create" because this method always publishes a complete file + * content: an unobserved target may only be created when absent, and an + * observed target must still carry the version token recorded at read time. * @returns Result of the save operation including any new problems detected */ async saveDirectly( @@ -1145,6 +1149,7 @@ export class DiffViewProvider { openFile: boolean = true, diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + writeKind: GuardedWriteKind = "create", ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1155,9 +1160,19 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file + // Publish through the S4 guarded-write API (epic #1375): an unobserved + // write to an existing file and a stale observed version are rejected + // with a re-read-then-retry remediation instead of overwriting the file. + // Concurrent in-process writes to the same path are already ordered by + // the guard's per-path FIFO chain, so no additional locking is added here. + const task = this.taskRef.deref() + if (!task) { + // Fail closed: without the owning task the observation registry is + // unreachable and the write cannot be guarded. + throw new Error("Cannot guard the write: the owning task is no longer available") + } await createDirectoriesForFile(absolutePath) - await safeWriteText(absolutePath, content) + await guardedWrite(task, relPath, content, writeKind) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index 511f0e7f3c..0303797c93 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -5,6 +5,13 @@ import delay from "delay" import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import * as fs from "fs/promises" + +import { computeVersionToken } from "../../../utils/versionToken" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { ObservationRegistry } from "../../../core/task/observationRegistry" +import type { Task } from "../../../core/task/Task" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -25,6 +32,12 @@ vi.mock("../../../services/file-safety/safeWriteText", () => ({ safeWriteText: vi.fn().mockResolvedValue(undefined), })) +// Mock the S1 version token (used by the S4 guarded write); the real +// computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + // Mock utils vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), @@ -33,6 +46,7 @@ vi.mock("../../../utils/fs", () => ({ // Mock path vi.mock("path", () => ({ resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), basename: vi.fn((path) => path.split("/").pop()), dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), join: (...args: string[]) => args.join("/"), @@ -159,8 +173,11 @@ describe("DiffViewProvider", () => { return mockWorkspaceEdit as any }) - // Create a mock Task instance + // Create a mock Task instance. The guarded write (S4b) consults the task's + // S2 observation registry, so the mock carries a real (in-memory) instance. mockTask = { + cwd: mockCwd, + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -793,6 +810,13 @@ describe("DiffViewProvider", () => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + // Baseline for the single-writer flow these tests encode: the file was read + // before the write, so the observation registry holds the version token the + // guarded write recomputes and compares, and the target exists on disk. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") + vi.mocked(fs.access).mockResolvedValue(undefined) }) it("should write content directly to file without opening diff view", async () => { @@ -868,6 +892,68 @@ describe("DiffViewProvider", () => { expect((diffViewProvider as any).relPath).toBe("test.ts") expect((diffViewProvider as any).newContent).toBe("new content") }) + + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "File already exists at /mock/cwd/test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + vi.mocked(fs.access).mockRejectedValue(enoent()) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) + + it("rejects an observed write whose version token is stale", async () => { + // The file changed on disk after the read that recorded "v1". + vi.mocked(computeVersionToken).mockResolvedValue("v2") + + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() + + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0, "edit"), + ).rejects.toThrow("File not read yet -- read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + }) + + it("recreates an observed file that vanished after the read", async () => { + vi.mocked(fs.access).mockRejectedValue(enoent()) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + }) }) describe("saveChanges method with diagnostic settings", () => { From abfbe7f789c00a8084901158cedb9e1ca1ac4982 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 19:59:46 +0800 Subject: [PATCH 23/46] feat(checkpoints): per-write checkpoints, task-start baseline, and perWriteCheckpoints setting (B1, #1375) --- packages/types/src/global-settings.ts | 13 + packages/types/src/vscode-extension-host.ts | 1 + src/core/task/Task.ts | 14 + src/core/task/__tests__/Task.spec.ts | 92 +++++ src/core/tools/ApplyPatchTool.ts | 67 +++- src/core/tools/EditFileTool.ts | 6 + src/core/tools/WriteToFileTool.ts | 8 + .../__tests__/applyPatchTool.execute.spec.ts | 331 ++++++++++++++++++ src/core/tools/__tests__/editFileTool.spec.ts | 42 +++ .../tools/__tests__/writeToFileTool.spec.ts | 77 ++++ src/core/webview/ClineProvider.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 43 +++ .../settings/CheckpointSettings.tsx | 21 +- .../src/components/settings/SettingsView.tsx | 4 + .../__tests__/CheckpointSettings.spec.tsx | 125 +++++++ .../src/context/ExtensionStateContext.tsx | 1 + .../__tests__/ExtensionStateContext.spec.tsx | 2 + webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 35 files changed, 904 insertions(+), 19 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..4f21ca9607 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -99,6 +99,13 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Whether per-write checkpoints and task-start baseline are enabled by default. + * Master switch for the B cluster of checkpoint features. + * @default true + */ +export const DEFAULT_PER_WRITE_CHECKPOINTS = true + /** * GlobalSettings */ @@ -200,6 +207,12 @@ export const globalSettingsSchema = z.object({ .min(MIN_CHECKPOINT_TIMEOUT_SECONDS) .max(MAX_CHECKPOINT_TIMEOUT_SECONDS) .optional(), + /** + * Whether to record a shadow-git checkpoint after every successful write_to_file, + * edit_file, and apply_patch (per-write checkpoints), plus a task-start baseline. + * @default true + */ + perWriteCheckpoints: z.boolean().optional(), ttsEnabled: z.boolean().optional(), ttsSpeed: z.number().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..20756d7a68 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -348,6 +348,7 @@ export type ExtensionState = Pick< enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) + perWriteCheckpoints: boolean maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..61203a3046 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -314,6 +314,9 @@ export class Task extends EventEmitter implements TaskLike { public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout + // B1: task-start baseline, recorded at most once (initiateTaskLoop also runs on resume). + private taskStartBaselineDone = false + // Tool Use consecutiveMistakeCount: number = 0 consecutiveMistakeLimit: number @@ -2492,6 +2495,17 @@ export class Task extends EventEmitter implements TaskLike { // arm needed. void getCheckpointService(this) + // B1 task-start baseline: a suppressed pre-task root commit (default-on). + if (!this.taskStartBaselineDone) { + this.taskStartBaselineDone = true + const baselineEnabled = (await this.providerRef.deref()?.getState())?.perWriteCheckpoints + if (baselineEnabled !== false) { + // allowEmpty=true so a clean workspace still produces the baseline + // commit; awaited so the first per-write checkpoint cannot interleave. + await this.checkpointSave(true, true) + } + } + let nextUserContent = userContent let includeFileDetails = true diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 37e228f887..4ca5d7495c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3289,6 +3289,98 @@ describe("Cline", () => { }) }) + describe("task-start baseline (B1 perWriteCheckpoints)", () => { + it("records one suppressed baseline checkpoint per Task instance at loop start", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue(state) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + }) + + it("records the baseline checkpoint when the setting is unset (default-on)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline unset task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + // Unset: the property is absent from the state, so default-on applies. + const unsetState = { ...state } + Reflect.deleteProperty(unsetState, "perWriteCheckpoints") + vi.spyOn(mockProvider, "getState").mockResolvedValue(unsetState as typeof state) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(saveSpy).toHaveBeenCalledWith(true, true) + }) + + it("does not record a baseline checkpoint when perWriteCheckpoints is disabled", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline disabled task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(task, "checkpointSave").mockResolvedValue(undefined) + const state = await mockProvider.getState() + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, perWriteCheckpoints: false }) + + task.abort = true + + await taskAccess.initiateTaskLoop([]) + + expect(saveSpy).not.toHaveBeenCalled() + }) + + it("awaits the baseline checkpoint before entering the request loop", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "baseline await task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveSpy = vi + .spyOn(task, "checkpointSave") + .mockImplementation(() => new Promise((resolve) => (resolveSave = resolve))) + const requestSpy = vi.spyOn(task, "recursivelyMakeClineRequests").mockResolvedValue(true) + vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...(await mockProvider.getState()) }) + const loopPromise = taskAccess.initiateTaskLoop([]) + + // The loop must not enter while the baseline checkpoint is still in flight. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(saveSpy).toHaveBeenCalledOnce() + expect(requestSpy).not.toHaveBeenCalled() + + resolveSave() + await loopPromise + expect(requestSpy).toHaveBeenCalled() + }) + }) + describe("start()", () => { it("should be a no-op if the task was already started in the constructor", () => { const task = new Task({ diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..f42a4ebf03 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -6,6 +6,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" +import { checkpointSave } from "../checkpoints" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" @@ -102,7 +103,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each file change + // Process each file change. The handlers report whether their file + // operation succeeded, so a rejected approval or a failed local write + // does not get checkpointed as if the patch had succeeded. + let patchSucceeded = true for (const change of changes) { const relPath = change.path const absolutePath = path.resolve(task.cwd, relPath) @@ -120,17 +124,39 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = + (await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected)) && + patchSucceeded } else if (change.type === "delete") { // Delete file - await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = + (await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected)) && + patchSucceeded } else if (change.type === "update") { // Update file - await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = + (await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + )) && patchSucceeded } } task.consecutiveMistakeCount = 0 + + // B1: one checkpoint for the whole patch (not per file), and only when + // every file operation succeeded. Live setting with default-on + // semantics: skip only when explicitly false. + if (patchSucceeded) { + const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { + void checkpointSave(task, false, true).catch(() => {}) + } + } } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() @@ -144,7 +170,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file already exists @@ -155,7 +181,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File already exists: ${relPath}. Use Update File instead.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } const newContent = change.newContent || "" @@ -209,7 +235,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return + return false } // Save the changes @@ -227,6 +253,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() + return true } private async handleDeleteFile( @@ -235,7 +262,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -246,7 +273,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -268,7 +295,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!didApprove) { pushToolResult("Delete operation was rejected by the user.") - return + return false } // Delete the file @@ -278,12 +305,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) task.processQueuedMessages() + return true } private async handleUpdateFile( @@ -293,7 +321,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -304,7 +332,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return + return false } const originalContent = change.originalContent || "" @@ -318,9 +346,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Generate and validate diff const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent) if (!diff) { + // A no-op change is not a failure: the patch processed cleanly and + // nothing was written, so the whole-patch success state is kept. pushToolResult(`No changes needed for '${relPath}'`) await task.diffViewProvider.reset() - return + return true } // Check experiment settings @@ -366,7 +396,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return + return false } // Handle file move if specified @@ -379,7 +409,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("rooignore_error", change.movePath) pushToolResult(formatResponse.rooIgnoreError(change.movePath)) await task.diffViewProvider.reset() - return + return false } // Check if destination path is write-protected @@ -391,7 +421,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return + return false } // Check if destination path is outside workspace @@ -403,7 +433,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return + return false } // Save new content to the new path @@ -447,6 +477,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() + return true } override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..0a3cb5d2e8 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -11,6 +11,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { checkpointSave } from "../../core/checkpoints" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -392,6 +393,7 @@ export class EditFileTool extends BaseTool<"edit_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const perWriteCheckpoints = state?.perWriteCheckpoints ?? true const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, @@ -463,6 +465,10 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) + if (perWriteCheckpoints) { + void checkpointSave(task, false, true).catch(() => {}) + } + await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index ae026b4b86..cf1500a510 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -14,6 +14,7 @@ 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 { checkpointSave } from "../checkpoints" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -103,6 +104,7 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const state = await provider?.getState() const diagnosticsEnabled = state?.diagnosticsEnabled ?? true const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const perWriteCheckpoints = state?.perWriteCheckpoints ?? true const isPreventFocusDisruptionEnabled = experiments.isEnabled( state?.experiments ?? {}, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, @@ -179,6 +181,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) + if (perWriteCheckpoints) { + // Await so the checkpoint (staging + commit) finishes before the next + // queued write starts; otherwise two writes can collapse into one commit. + await checkpointSave(task, false, true).catch(() => {}) + } + await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..f5f64c939e 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,13 +4,29 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { checkpointSave } from "../../checkpoints" import { ApplyPatchTool } from "../ApplyPatchTool" +// The vi.mock factory exposes the fs/promises functions under a `default` +// property (matching the SUT's default import), which the static module type +// does not declare; cast once at this boundary rather than at each call site. +const mockedFsPromises = vi.mocked( + fsPromises as unknown as { + default: { + unlink: MockedFunction + writeFile: MockedFunction + } + }, +) + vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), unlink: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), }, })) @@ -22,6 +38,10 @@ vi.mock("../../../utils/pathUtils", () => ({ isPathOutsideWorkspace: vi.fn().mockReturnValue(false), })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + describe("ApplyPatchTool.execute - delete file success path", () => { const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction @@ -38,6 +58,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "providerRef" + | "diffViewProvider" + | "fileContextTracker" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +75,11 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({}), + }), + } as unknown as Task["providerRef"], recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -63,6 +91,21 @@ describe("ApplyPatchTool.execute - delete file success path", () => { say: vi.fn().mockResolvedValue(undefined), processQueuedMessages: vi.fn(), didEditFile: false, + diffViewProvider: { + editType: "modify", + originalContent: undefined, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + revertChanges: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + saveDirectly: vi.fn().mockResolvedValue({ finalContent: "saved" }), + saveChanges: vi.fn().mockResolvedValue(undefined), + pushToolWriteResult: vi.fn().mockResolvedValue("File saved successfully"), + } as unknown as Task["diffViewProvider"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], } mockAskApproval = vi.fn().mockResolvedValue(true) @@ -93,4 +136,292 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolUsage).not.toHaveBeenCalled() expect(mockTask.recordToolError).not.toHaveBeenCalled() }) + + describe("per-write checkpoints (B1)", () => { + const deletePatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint for the whole patch (default-on)", async () => { + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + // Structural cast for the test double (matches the mock style used for the controllers above). + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ perWriteCheckpoints: false }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when patch processing fails", async () => { + // A malformed patch fails at parse time, before the change loop and + // the post-loop checkpoint hook. + const badPatch = `*** Begin Patch +*** This is not a valid hunk +*** End Patch` + + await tool.execute({ patch: badPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + }) + + describe("checkpoint only for fully successful patches (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + const deletePatch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++hello ++world +*** End Patch` + const updatePatch = `*** Begin Patch +*** Update File: src/test.ts +@@ +-original file content ++modified content +*** End Patch` + const updateNoDiffPatch = `*** Begin Patch +*** Update File: src/test.ts +@@ +-original file content ++original file content +*** End Patch` + const movePatch = `*** Begin Patch +*** Update File: src/test.ts +*** Move to: src/moved.ts +@@ +-original file content ++modified content +*** End Patch` + + it("does not record a checkpoint when the user rejects the patch", async () => { + // Rejected approval: the handler early-returns without recording a + // tool error, so the success flag must come from the handler itself. + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Delete operation was rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the file to delete does not exist", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the delete write fails", async () => { + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY")) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to delete file")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the added file already exists", async () => { + // fileExistsAtPath resolves true by default in beforeEach. + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the user rejects the add", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the add succeeds", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("does not record a checkpoint when the file to update does not exist", async () => { + mockedFileExistsAtPath.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File not found")) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the update is a no-op (no changes needed)", async () => { + // A no-op change is not a failure, so the whole-patch checkpoint still runs. + + await tool.execute({ patch: updateNoDiffPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("No changes needed")) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("does not record a checkpoint when the user rejects the update", async () => { + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is not allowed", async () => { + // First validateAccess call (source path, in the execute loop) passes; + // the move destination check inside the handler fails. + const validateAccess = ( + mockTask["rooIgnoreController"] as unknown as { validateAccess: MockedFunction<() => boolean> } + ).validateAccess + validateAccess.mockReturnValueOnce(true).mockReturnValue(false) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/moved.ts") + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is write-protected", async () => { + // Source path check (execute loop) passes; the move destination fails. + const isWriteProtected = ( + mockTask["rooProtectedController"] as unknown as { + isWriteProtected: MockedFunction<(p: string) => boolean> + } + ).isWriteProtected + isWriteProtected.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to write-protected path"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the move destination is outside the workspace", async () => { + // Source path (first call) is inside; the move destination (second) + // call is outside the workspace. + mockedIsPathOutsideWorkspace.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace"), + ) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("records a checkpoint when the move succeeds", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // path is platform-dependent (Windows resolves cwd to a drive path); + // assert on the written content instead. + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith( + expect.any(String), + "modified content\n", + "utf8", + ) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + + it("records a checkpoint when the in-place update succeeds", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 1ff8d52a8d..5b645a0074 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -7,6 +7,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" +import { checkpointSave } from "../../checkpoints" import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ @@ -59,6 +60,10 @@ vi.mock("../../diff/stats", () => ({ computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("vscode", () => ({ window: { showWarningMessage: vi.fn().mockResolvedValue(undefined), @@ -774,4 +779,41 @@ describe("editFileTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("per-write checkpoints (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint after a successful edit (default-on)", async () => { + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + perWriteCheckpoints: false, + }), + }) + + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the edit fails", async () => { + mockTask.diffViewProvider.saveChanges.mockRejectedValue(new Error("save failed")) + + await executeEditFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 52a7e3c052..f9286af36d 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -8,6 +8,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 { checkpointSave } from "../../checkpoints" import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { @@ -89,6 +90,10 @@ vi.mock("../../ignore/RooIgnoreController", () => ({ }, })) +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + describe("writeToFileTool", () => { // Test data const testFilePath = "test/file.txt" @@ -472,4 +477,76 @@ describe("writeToFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("handling partial write_to_file", expect.any(Error)) }) }) + + describe("per-write checkpoints (B1)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + + it("records one suppressed checkpoint after a successful write (default-on)", async () => { + await executeWriteFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true) + }) + + it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi + .fn() + .mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, perWriteCheckpoints: false }), + }) + + await executeWriteFileTool({}) + + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("does not record a checkpoint when the write fails", async () => { + mockCline.diffViewProvider.open.mockRejectedValue(new Error("write failed")) + + await executeWriteFileTool({}) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockedCheckpointSave).not.toHaveBeenCalled() + }) + + it("waits for the per-write checkpoint before the tool completes", async () => { + let checkpointStarted = false + let releaseCheckpoint: () => void = () => {} + mockedCheckpointSave.mockImplementationOnce(() => { + checkpointStarted = true + return new Promise((resolve) => { + releaseCheckpoint = () => resolve(undefined) + }) + }) + const processQueuedSpy = vi.fn() + mockCline.processQueuedMessages = processQueuedSpy + + const toolPromise = executeWriteFileTool({}) + + // Advance microtasks until the tool reaches the checkpoint call (all + // preceding awaits are mocked resolutions, no real timers involved). + for (let i = 0; i < 50 && !checkpointStarted; i++) { + await Promise.resolve() + } + expect(checkpointStarted).toBe(true) + + let settled = false + void toolPromise.then(() => { + settled = true + }) + + // The tool must not complete while the checkpoint is still + // staging/committing: a later write started by the task loop would + // otherwise collapse into the same (or a missing) commit. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) + expect(processQueuedSpy).not.toHaveBeenCalled() + + releaseCheckpoint() + await toolPromise + expect(settled).toBe(true) + expect(processQueuedSpy).toHaveBeenCalledOnce() + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4621cb3fc4..481904ed57 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -51,6 +51,7 @@ import { ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_PER_WRITE_CHECKPOINTS, getModelId, isRetiredProvider, providerIdentifiers, @@ -2556,6 +2557,7 @@ export class ClineProvider ttsSpeed, enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, soundVolume, writeDelayMs, diffFuzzyThreshold, @@ -2715,6 +2717,7 @@ export class ClineProvider ttsSpeed: ttsSpeed ?? 1.0, enableCheckpoints: enableCheckpoints ?? true, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands: mergedAllowedCommands, @@ -2951,6 +2954,7 @@ export class ClineProvider ttsSpeed: stateValues.ttsSpeed ?? 1.0, enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: stateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 731124cccc..7ec6bb782b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -720,6 +720,7 @@ describe("ClineProvider", () => { soundEnabled: false, ttsEnabled: false, enableCheckpoints: false, + perWriteCheckpoints: false, writeDelayMs: 1000, mcpEnabled: true, mode: defaultModeSlug, @@ -1401,6 +1402,48 @@ describe("ClineProvider", () => { expect(state.destructiveCommandGuardEnabled).toBe(false) }) + test("getState returns the saved per-write checkpoints setting", async () => { + await provider.contextProxy.setValue("perWriteCheckpoints", false) + + const state = await provider.getState() + + expect(state.perWriteCheckpoints).toBe(false) + }) + + test("getState defaults per-write checkpoints to true when unset", async () => { + const state = await provider.getState() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getStateToPostToWebview returns the saved per-write checkpoints setting", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("perWriteCheckpoints", true) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(true) + }) + + test("getStateToPostToWebview returns false when per-write checkpoints is saved as false", async () => { + // The default is also true, so only an explicit false proves that the + // stored value (rather than the default) reaches the webview state. + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("perWriteCheckpoints", false) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(false) + }) + + test("getStateToPostToWebview defaults per-write checkpoints to true when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.perWriteCheckpoints).toBe(true) + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index dd28f6615f..7ea12ef873 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -13,17 +13,20 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, + DEFAULT_PER_WRITE_CHECKPOINTS, } from "@roo-code/types" type CheckpointSettingsProps = HTMLAttributes & { enableCheckpoints?: boolean checkpointTimeout?: number - setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointTimeout"> + perWriteCheckpoints?: boolean + setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointTimeout" | "perWriteCheckpoints"> } export const CheckpointSettings = ({ enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, setCachedStateField, ...props }: CheckpointSettingsProps) => { @@ -33,6 +36,22 @@ export const CheckpointSettings = ({ {t("settings:sections.checkpoints")}
+ + { + setCachedStateField("perWriteCheckpoints", e.target.checked) + }}> + {t("settings:checkpoints.perWrite.label")} + +
+ {t("settings:checkpoints.perWrite.description")} +
+
+ (({ onDone, t autoCondenseContextPercent, enableCheckpoints, checkpointTimeout, + perWriteCheckpoints, experiments, maxOpenTabsContext, maxWorkspaceFiles, @@ -410,6 +412,7 @@ const SettingsView = forwardRef(({ onDone, t ttsSpeed, enableCheckpoints: enableCheckpoints ?? false, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, writeDelayMs, diffFuzzyThreshold, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, @@ -847,6 +850,7 @@ const SettingsView = forwardRef(({ onDone, t )} diff --git a/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx new file mode 100644 index 0000000000..b08c15ace7 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx @@ -0,0 +1,125 @@ +// npx vitest src/components/settings/__tests__/CheckpointSettings.spec.tsx + +import { render, screen, fireEvent } from "@/utils/test-utils" +import { CheckpointSettings } from "../CheckpointSettings" + +// Mock the translation hook +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + if (key === "settings:checkpoints.perWrite.label") { + return "Checkpoint after each file write" + } + if (key === "settings:checkpoints.perWrite.description") { + return "Record a checkpoint snapshot after every successful file write by the agent" + } + return key + }, + }), +})) + +// Mock the UI components (async factory: vi.importActual resolves asynchronously). +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Slider: ({ defaultValue, onValueChange, "data-testid": dataTestId }: any) => ( + onValueChange?.([100])} + data-testid={dataTestId} + role="slider" + /> + ), + } +}) + +// Mock vscode utilities +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock VSCode components to behave like standard HTML elements +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ checked, onChange, children, ...props }: any) => ( + + ), + VSCodeLink: ({ children, ...props }: any) => {children}, +})) + +describe("CheckpointSettings", () => { + const setCachedStateField = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the per-write checkpoints checkbox checked by default when the value is unset", () => { + render() + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + expect(checkbox).toBeChecked() + }) + + it("unchecks the per-write checkpoints checkbox when the saved value is false", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + expect(checkbox).not.toBeChecked() + }) + + it("keeps the per-write checkpoints checkbox checked when the saved value is true", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + expect(checkbox).toBeChecked() + }) + + it("caches a toggle to enable per-write checkpoints when the user checks the box", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("perWriteCheckpoints", true) + }) + + it("caches a toggle to disable per-write checkpoints when the user unchecks the box", () => { + render() + + const checkbox = screen.getByRole("checkbox", { name: "Checkpoint after each file write" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("perWriteCheckpoints", false) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 377c8eb721..203594275d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -211,6 +211,7 @@ const createInitialExtensionState = (): ExtensionState => ({ ttsEnabled: false, ttsSpeed: 1.0, enableCheckpoints: true, + perWriteCheckpoints: true, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds language: "en", // Default language code writeDelayMs: 1000, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 23ac911585..f0655b55e0 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -407,6 +407,7 @@ describe("mergeExtensionState", () => { taskHistory: [], shouldShowAnnouncement: false, enableCheckpoints: true, + perWriteCheckpoints: true, writeDelayMs: 1000, mode: "default", experiments: {} as Record, @@ -477,6 +478,7 @@ describe("mergeExtensionState", () => { taskHistory: [], shouldShowAnnouncement: false, enableCheckpoints: true, + perWriteCheckpoints: true, writeDelayMs: 1000, mode: "default", experiments: {} as Record, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 52805e74e8..dca198cc7b 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Habilitar punts de control automàtics", "description": "Quan està habilitat, Zoo crearà automàticament punts de control durant l'execució de tasques, facilitant la revisió de canvis o la reversió a estats anteriors. <0>Més informació" + }, + "perWrite": { + "label": "Punt de control després de cada escriptura de fitxer", + "description": "Registra una instantània de punt de control després de cada escriptura de fitxer reeixida de l’agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index b895717422..530c799733 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Automatische Kontrollpunkte aktivieren", "description": "Wenn aktiviert, erstellt Zoo automatisch Kontrollpunkte während der Aufgabenausführung, was die Überprüfung von Änderungen oder die Rückkehr zu früheren Zuständen erleichtert. <0>Mehr erfahren" + }, + "perWrite": { + "label": "Kontrollpunkt nach jedem Dateischreibvorgang", + "description": "Ein Kontrollpunkt-Snapshot wird nach jedem erfolgreichen Dateischreibvorgang des Agents erfasst" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index eaa37b7034..5f8b40ade2 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -781,6 +781,10 @@ "enable": { "label": "Enable automatic checkpoints", "description": "When enabled, Zoo will automatically create checkpoints during task execution, making it easy to review changes or revert to earlier states. <0>Learn more" + }, + "perWrite": { + "label": "Checkpoint after each file write", + "description": "Record a checkpoint snapshot after every successful file write by the agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index abb8a60609..704f8d1d31 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Habilitar puntos de control automáticos", "description": "Cuando está habilitado, Zoo creará automáticamente puntos de control durante la ejecución de tareas, facilitando la revisión de cambios o la reversión a estados anteriores. <0>Más información" + }, + "perWrite": { + "label": "Punto de control después de cada escritura de archivo", + "description": "Registra una instantánea de punto de control después de cada escritura de archivo exitosa del agente" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 272f21a6ee..5db4e0dfe7 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Activer les points de contrôle automatiques", "description": "Lorsque cette option est activée, Zoo créera automatiquement des points de contrôle pendant l'exécution des tâches, facilitant la révision des modifications ou le retour à des états antérieurs. <0>En savoir plus" + }, + "perWrite": { + "label": "Point de contrôle après chaque écriture de fichier", + "description": "Enregistre un instantané de point de contrôle après chaque écriture de fichier réussie par l’agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0a4152b17a..4570f85117 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "स्वचालित चेकपॉइंट सक्षम करें", "description": "जब सक्षम होता है, तो Zoo कार्य निष्पादन के दौरान स्वचालित रूप से चेकपॉइंट बनाएगा, जिससे परिवर्तनों की समीक्षा करना या पहले की स्थितियों पर वापस जाना आसान हो जाएगा। <0>अधिक जानें" + }, + "perWrite": { + "label": "हर फ़ाइल लिखने के बाद चेकपॉइंट", + "description": "एजेंट द्वारा हर सफल फ़ाइल लिखने के बाद एक चेकपॉइंट स्नैपशॉट दर्ज किया जाता है" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b8abe9ab25..2918f14870 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Aktifkan checkpoint otomatis", "description": "Ketika diaktifkan, Zoo akan secara otomatis membuat checkpoint selama eksekusi tugas, memudahkan untuk meninjau perubahan atau kembali ke state sebelumnya. <0>Pelajari lebih lanjut" + }, + "perWrite": { + "label": "Checkpoint setelah setiap penulisan file", + "description": "Merekam snapshot checkpoint setelah setiap penulisan file yang berhasil oleh agen" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index e49ede9cec..6909e6a1a2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Abilita punti di controllo automatici", "description": "Quando abilitato, Zoo creerà automaticamente punti di controllo durante l'esecuzione dei compiti, facilitando la revisione delle modifiche o il ritorno a stati precedenti. <0>Scopri di più" + }, + "perWrite": { + "label": "Punto di controllo dopo ogni scrittura del file", + "description": "Registra uno snapshot di punto di controllo dopo ogni scrittura del file riuscita dell’agente" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index d58c86c95d..6deaeca4d6 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "自動チェックポイントを有効化", "description": "有効にすると、Zooはタスク実行中に自動的にチェックポイントを作成し、変更の確認や以前の状態への復帰を容易にします。 <0>詳細情報" + }, + "perWrite": { + "label": "ファイルの書き込みごとにチェックポイント", + "description": "エージェントによる各ファイルの書き込み成功後にチェックポイントのスナップショットを記録します" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 68ce8b2523..a6c6583347 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "자동 체크포인트 활성화", "description": "활성화되면 Zoo는 작업 실행 중에 자동으로 체크포인트를 생성하여 변경 사항을 검토하거나 이전 상태로 되돌리기 쉽게 합니다. <0>더 알아보기" + }, + "perWrite": { + "label": "파일을 쓸 때마다 체크포인트", + "description": "에이전트가 파일 쓰기에 성공할 때마다 체크포인트 스냅샷을 기록합니다" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 8d90d7747e..77f6f5797b 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Automatische checkpoints inschakelen", "description": "Indien ingeschakeld, maakt Zoo automatisch checkpoints tijdens het uitvoeren van taken, zodat je eenvoudig wijzigingen kunt bekijken of terugzetten. <0>Meer informatie" + }, + "perWrite": { + "label": "Checkpoint na elke bestandsschrijving", + "description": "Neemt een checkpoint-snapshot op na elke succesvolle bestandsschrijving door de agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ffc1cdf1a4..49b341cb9f 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Włącz automatyczne punkty kontrolne", "description": "Gdy włączone, Zoo automatycznie utworzy punkty kontrolne podczas wykonywania zadań, ułatwiając przeglądanie zmian lub powrót do wcześniejszych stanów. <0>Dowiedz się więcej" + }, + "perWrite": { + "label": "Punkt kontrolny po każdym zapisaniu pliku", + "description": "Rejestruje migawkę punktu kontrolnego po każdym udanym zapisaniu pliku przez agenta" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index cf92b76ac7..71694fe203 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Ativar pontos de verificação automáticos", "description": "Quando ativado, o Zoo criará automaticamente pontos de verificação durante a execução de tarefas, facilitando a revisão de alterações ou o retorno a estados anteriores. <0>Saiba mais" + }, + "perWrite": { + "label": "Ponto de verificação após cada gravação de arquivo", + "description": "Registra um snapshot de ponto de verificação após cada gravação de arquivo bem-sucedida pelo agente" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 23ff32faa9..e9a3f5938b 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Включить автоматические контрольные точки", "description": "Если включено, Zoo будет автоматически создавать контрольные точки во время выполнения задач, что упрощает просмотр изменений или возврат к предыдущим состояниям. <0>Подробнее" + }, + "perWrite": { + "label": "Контрольная точка после каждой записи файла", + "description": "Записывает снимок контрольной точки после каждой успешной записи файла агентом" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index f674e116d2..aff7402428 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Otomatik kontrol noktalarını etkinleştir", "description": "Etkinleştirildiğinde, Zoo görev yürütme sırasında otomatik olarak kontrol noktaları oluşturarak değişiklikleri gözden geçirmeyi veya önceki durumlara dönmeyi kolaylaştırır. <0>Daha fazla bilgi" + }, + "perWrite": { + "label": "Her dosya yazımından sonra kontrol noktası", + "description": "Ajanın her başarılı dosya yazımından sonra bir kontrol noktası görüntüsü kaydeder" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4b908ca658..e944a56780 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "Bật điểm kiểm tra tự động", "description": "Khi được bật, Zoo sẽ tự động tạo các điểm kiểm tra trong quá trình thực hiện nhiệm vụ, giúp dễ dàng xem lại các thay đổi hoặc quay lại trạng thái trước đó. <0>Tìm hiểu thêm" + }, + "perWrite": { + "label": "Điểm kiểm tra sau mỗi lần ghi file", + "description": "Ghi lại ảnh chụp nhanh điểm kiểm tra sau mỗi lần ghi file thành công của agent" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index d79edca302..05d04463e1 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -701,6 +701,10 @@ "enable": { "label": "启用自动存档点", "description": "开启后自动创建任务存档点,方便回溯修改。 <0>了解更多" + }, + "perWrite": { + "label": "每次文件写入后创建存档点", + "description": "智能体每次成功写入文件后都会记录一个存档点快照" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 250cc2111b..a2ef47b841 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -728,6 +728,10 @@ "enable": { "label": "啟用自動檢查點", "description": "啟用後,Zoo 將在工作執行期間自動建立檢查點,方便檢視變更或回到較早的狀態。 <0>了解更多" + }, + "perWrite": { + "label": "每次檔案寫入後建立檢查點", + "description": "代理每次成功寫入檔案後都會記錄一個檢查點快照" } }, "notifications": { From 93a832906677fe35f567d7875800ddff1c2f3a52 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 27 Aug 2026 23:44:12 +0800 Subject: [PATCH 24/46] feat(checkpoints): per-task change journal with torn-tail repair (B2, #1375) --- .../__tests__/changeJournal.spec.ts | 138 ++++++++++++ .../__tests__/checkpointJournal.test.ts | 206 ++++++++++++++++++ src/core/checkpoints/changeJournal.ts | 99 +++++++++ src/core/checkpoints/index.ts | 53 ++++- src/core/tools/ApplyPatchTool.ts | 162 ++++++++++---- src/core/tools/EditFileTool.ts | 9 +- src/core/tools/WriteToFileTool.ts | 27 ++- .../__tests__/applyPatchTool.execute.spec.ts | 201 ++++++++++++++++- src/core/tools/__tests__/editFileTool.spec.ts | 46 +++- .../tools/__tests__/writeToFileTool.spec.ts | 55 ++++- 10 files changed, 940 insertions(+), 56 deletions(-) create mode 100644 src/core/checkpoints/__tests__/changeJournal.spec.ts create mode 100644 src/core/checkpoints/__tests__/checkpointJournal.test.ts create mode 100644 src/core/checkpoints/changeJournal.ts diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts new file mode 100644 index 0000000000..4f3bbda053 --- /dev/null +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -0,0 +1,138 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal" + +describe("changeJournal", () => { + const taskId = "test-task" + + let tmpRoot: string + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-")) + }) + + afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) + }) + + function entry(overrides: Partial = {}): ChangeJournalEntry { + return { + path: "src/foo.ts", + operation: "create", + checkpointId: "abc12345", + ...overrides, + } + } + + describe("appendChange", () => { + it("writes one JSON line per entry with the documented field shape", async () => { + await appendChange(tmpRoot, taskId, entry({ operation: "create", checkpointId: "aaa" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + const lines = raw.split("\n").filter((line) => line !== "") + expect(lines).toHaveLength(1) + const parsed = JSON.parse(lines[0]) as ChangeJournalEntry + expect(parsed.path).toBe("src/foo.ts") + expect(parsed.operation).toBe("create") + expect(parsed.checkpointId).toBe("aaa") + }) + + it("appends multiple entries sequentially", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" })) + + const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2) + }) + }) + + describe("loadChanges", () => { + it("returns [] for an absent journal file", async () => { + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("returns [] for an empty journal file", async () => { + await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true }) + await fs.writeFile(journalPath(tmpRoot, taskId), "") + + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("parses all entries in order with a clean tail", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" })) + await appendChange(tmpRoot, taskId, entry({ checkpointId: "z" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(3) + expect(result[0].checkpointId).toBe("x") + expect(result[1].checkpointId).toBe("y") + expect(result[2].checkpointId).toBe("z") + }) + + it("parses a journal whose final line has no trailing newline", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Rewrite the file without the trailing newline of the last line. + const filePath = journalPath(tmpRoot, taskId) + const content = (await fs.readFile(filePath, "utf8")).replace(/\n$/, "") + await fs.writeFile(filePath, content) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("ok") + }) + + it("discards a torn final line and returns the complete entries", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Append a second line truncated mid-content, with no trailing newline. + await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd') + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("ok") + }) + + it("skips a corrupt middle line and still loads the later valid entries", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" })) + + // Corrupt the first line in place, then append a valid entry after it. + await fs.writeFile( + journalPath(tmpRoot, taskId), + '"{"path":"src/corrupt.ts","operation":"update"\n' + JSON.stringify(entry({ checkpointId: "after" })) + "\n", + ) + + const result = await loadChanges(tmpRoot, taskId) + expect(result).toHaveLength(1) + expect(result[0].checkpointId).toBe("after") + }) + + it("does not throw when the entire journal is torn", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" })) + + // Truncate to a single character — definitely invalid JSON. + await fs.writeFile(journalPath(tmpRoot, taskId), "{") + + expect(await loadChanges(tmpRoot, taskId)).toEqual([]) + }) + + it("includes diffStats when present", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "s", diffStats: { additions: 5, deletions: 2 } })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result[0].diffStats).toEqual({ additions: 5, deletions: 2 }) + }) + + it("omits diffStats when not provided", async () => { + await appendChange(tmpRoot, taskId, entry({ checkpointId: "n" })) + + const result = await loadChanges(tmpRoot, taskId) + expect(result[0].diffStats).toBeUndefined() + }) + }) +}) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts new file mode 100644 index 0000000000..5134c3a484 --- /dev/null +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -0,0 +1,206 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest" + +import type { Task } from "../../task/Task" +import { loadChanges } from "../changeJournal" +import { checkpointSave, type CheckpointWriteInfo } from "../index" + +// Mock the VS Code API surface (index.ts imports vscode at module level). +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({})), + }, + Uri: { + file: vi.fn((p: string) => ({ fsPath: p })), + parse: vi.fn((uri: string) => ({ with: vi.fn(() => ({})) })), + }, + commands: { + executeCommand: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureCheckpointCreated: vi.fn(), + captureCheckpointRestored: vi.fn(), + captureCheckpointDiffed: vi.fn(), + }, + }, +})) + +vi.mock("../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/test/workspace"), +})) + +vi.mock("../../../utils/git", () => ({ + checkGitInstalled: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn(), +})) + +// The real service would require a git repo; the checkpointSave under test +// only needs the pre-initialized service on the task. +vi.mock("../../../services/checkpoints") + +const TASK_ID = "journal-test-task" +const COMMIT = "test-commit-hash" + +interface ServiceLike { + isInitialized: boolean + saveCheckpoint: (...args: unknown[]) => Promise +} + +interface ProviderLike { + context: { globalStorageUri: { fsPath: string } } + log: (...args: unknown[]) => void + postMessageToWebview: (...args: unknown[]) => void +} + +interface TaskLike { + taskId: string + enableCheckpoints: boolean + checkpointService: ServiceLike + checkpointServiceInitializing: boolean + providerRef: { deref: () => ProviderLike | undefined } +} + +describe("checkpointSave change-journal wiring (B2)", () => { + let tmpStorageDir: string + let saveCheckpointSpy: Mock + let mockProvider: ProviderLike + let mockTask: TaskLike + const write: CheckpointWriteInfo = { path: "src/foo.ts", operation: "create", diffStats: { additions: 3, deletions: 0 } } + + beforeEach(async () => { + tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-wiring-")) + saveCheckpointSpy = vi.fn().mockResolvedValue({ commit: COMMIT }) + mockProvider = { + context: { globalStorageUri: { fsPath: tmpStorageDir } }, + log: vi.fn(), + postMessageToWebview: vi.fn(), + } + // Structural test double for Task (the class is not instantiated at + // this unit layer); the cast is safe because the fields checkpointSave + // reads are exactly these. + mockTask = { + taskId: TASK_ID, + enableCheckpoints: true, + checkpointService: { isInitialized: true, saveCheckpoint: saveCheckpointSpy }, + checkpointServiceInitializing: false, + providerRef: { deref: () => mockProvider }, + } + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(tmpStorageDir, { recursive: true, force: true }) + }) + + it("appends exactly one journal line referencing the B1 checkpoint id for a per-write save", async () => { + await checkpointSave(mockTask as Task, false, true, write) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0]).toEqual({ + path: "src/foo.ts", + operation: "create", + checkpointId: COMMIT, + diffStats: { additions: 3, deletions: 0 }, + }) + + // The raw file holds exactly one JSON line. + const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl") + const raw = await fs.readFile(journalFile, "utf8") + expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(1) + }) + + it("omits diffStats in the journal entry when not provided", async () => { + await checkpointSave(mockTask as Task, false, true, { path: "src/bar.ts", operation: "update" }) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(1) + expect(entries[0].path).toBe("src/bar.ts") + expect(entries[0].operation).toBe("update") + expect(entries[0].checkpointId).toBe(COMMIT) + expect(entries[0].diffStats).toBeUndefined() + }) + + it("does not write a journal entry for non-write checkpoint saves (task-start baseline)", async () => { + await checkpointSave(mockTask as Task) + + const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl") + await expect(fs.stat(journalFile)).rejects.toThrow() + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("appends one entry per file change for a multi-file write (apply-patch shape)", async () => { + await checkpointSave(mockTask as Task, false, true, [ + { path: "src/a.ts", operation: "create" }, + { path: "src/b.ts", operation: "update" }, + { path: "src/c.ts", operation: "delete" }, + ]) + + const entries = await loadChanges(tmpStorageDir, TASK_ID) + expect(entries).toHaveLength(3) + // Every entry references the single checkpoint of the whole patch. + expect(entries.map((entry) => entry.checkpointId)).toEqual([COMMIT, COMMIT, COMMIT]) + expect(entries.map((entry) => entry.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]) + expect(entries.map((entry) => entry.operation)).toEqual(["create", "update", "delete"]) + }) + + it("keeps the existing error-swallowing behavior and skips the journal on save failure", async () => { + saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded")) + + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined() + expect(mockTask.enableCheckpoints).toBe(false) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("does not write a journal entry when the checkpoint save is a no-op (empty commit)", async () => { + saveCheckpointSpy.mockResolvedValueOnce(undefined) + + await checkpointSave(mockTask as Task, false, true, write) + + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + expect(mockTask.enableCheckpoints).toBe(true) + }) + + it("does not crash when the provider has no globalStorageDir", async () => { + mockTask.providerRef = { deref: () => undefined } + + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + expect(mockTask.enableCheckpoints).toBe(true) + expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([]) + }) + + it("logs and continues when the journal cannot be written (checkpoints stay enabled)", async () => { + // Block the per-task checkpoint dir so the journal mkdir/append fails. + const taskDir = path.join(tmpStorageDir, "tasks", TASK_ID) + await fs.mkdir(taskDir, { recursive: true }) + await fs.writeFile(path.join(taskDir, "checkpoints"), "blocker") + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined) + + // The journal failure is swallowed (logged, not rethrown), so the + // checkpoint result still resolves exactly as without journaling. + await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT }) + + expect(mockTask.enableCheckpoints).toBe(true) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("failed to append change journal entry"), + expect.anything(), + ) + }) +}) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts new file mode 100644 index 0000000000..88983b3663 --- /dev/null +++ b/src/core/checkpoints/changeJournal.ts @@ -0,0 +1,99 @@ +import fs from "fs/promises" +import * as path from "path" + +/** + * A single entry in the per-task change journal (changes.jsonl). + * + * One line is appended for every successful file write that goes through a + * B1 per-write checkpoint hook. WriteToFileTool and EditFileTool emit one + * entry per write; ApplyPatchTool emits one entry per file change of a fully + * successful patch — those entries all reference the single B1 checkpoint + * that the patch's post-loop hook saves for the whole patch. The task-start + * baseline never produces an entry (it is not a file write). + */ +export interface ChangeJournalEntry { + /** The file path as the tool knows it (relative to task cwd). */ + path: string + /** "create" | "update" | "delete" — derived from what the tool did. */ + operation: "create" | "update" | "delete" + /** The B1 checkpoint commit SHA for this write (from checkpointSave result). */ + checkpointId: string + /** { additions, deletions } from the approval diff; null/omit when not computable. */ + diffStats?: { additions: number; deletions: number } +} + +const JOURNAL_FILENAME = "changes.jsonl" + +/** Derive the per-task checkpoint directory from globalStorageDir and taskId. */ +function taskCheckpointDir(globalStorageDir: string, taskId: string): string { + return path.join(globalStorageDir, "tasks", taskId, "checkpoints") +} + +/** Journal file path for a given task. */ +export function journalPath(globalStorageDir: string, taskId: string): string { + return path.join(taskCheckpointDir(globalStorageDir, taskId), JOURNAL_FILENAME) +} + +/** + * Append one change-journal entry to the per-task changes.jsonl file. + * + * Uses appendFile so each write is a single syscall — minimal torn-write risk. + * Creates parent directories if they don't exist yet (e.g. first checkpoint). + */ +export async function appendChange( + globalStorageDir: string, + taskId: string, + entry: ChangeJournalEntry, +): Promise { + const filePath = journalPath(globalStorageDir, taskId) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const line = JSON.stringify(entry) + "\n" + await fs.appendFile(filePath, line) +} + +/** + * Load all change-journal entries for a task. + * + * Torn-tail repair: if the final line is truncated (JSON.parse fails), it is + * silently discarded. The rest of the file is returned in order. An absent + * or empty journal returns []. + */ +export async function loadChanges( + globalStorageDir: string, + taskId: string, +): Promise { + const filePath = journalPath(globalStorageDir, taskId) + + let content: string + try { + content = await fs.readFile(filePath, "utf8") + } catch { + // File absent or unreadable → empty journal. + return [] + } + + if (!content.trim()) { + return [] + } + + const lines = content.split("\n") + // Remove trailing empty line from a file that ends with \n. + if (lines[lines.length - 1] === "") { + lines.pop() + } + + const entries: ChangeJournalEntry[] = [] + for (let i = 0; i < lines.length; i++) { + try { + entries.push(JSON.parse(lines[i]) as ChangeJournalEntry) + } catch { + // A corrupt line before the final line (e.g. a partially flushed + // append) must not hide the valid entries after it. The final line + // is still discarded as a torn tail — `continue` at the last index + // ends the loop either way. + continue + } + } + + return entries +} diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 26a137b939..bcfafb5cd6 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -16,6 +16,8 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" +import { appendChange, ChangeJournalEntry } from "./changeJournal" + const WARNING_THRESHOLD_MS = 5000 function sendCheckpointInitWarn(task: Task, type?: "WAIT_TIMEOUT" | "INIT_TIMEOUT", timeout?: number) { @@ -209,7 +211,28 @@ async function checkGitInstallation( } } -export async function checkpointSave(task: Task, force = false, suppressMessage = false) { +/** + * Write metadata for the per-task change journal (B2). + * + * `path` is the file path as the tool knows it (relative to the task cwd), + * consistent with what the B1 per-write checkpoint hooks see. `diffStats` is + * the { additions, deletions } pair from the approval diff when it was + * computable; omitted otherwise. ApplyPatchTool passes one entry per file + * change of a fully successful patch (an array), all sharing the single + * checkpoint the patch's post-loop hook saves. + */ +export type CheckpointWriteInfo = { + path: string + operation: "create" | "update" | "delete" + diffStats?: { additions: number; deletions: number } +} + +export async function checkpointSave( + task: Task, + force = false, + suppressMessage = false, + write?: CheckpointWriteInfo | CheckpointWriteInfo[], +) { const service = await getCheckpointService(task) if (!service) { @@ -221,6 +244,34 @@ export async function checkpointSave(task: Task, force = false, suppressMessage // Start the checkpoint process in the background. return service .saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage }) + .then(async (result) => { + // B2: record successful file writes in the per-task change journal. + // Only a real commit produces an entry (an empty or failed save + // resolves to undefined / rejects), and non-write checkpoint calls + // (e.g. the task-start baseline) pass no `write` value at all. + if (result?.commit && write) { + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + if (globalStorageDir) { + const writes = Array.isArray(write) ? write : [write] + // Append sequentially so journal lines preserve write order. A + // journal failure is logged here and never propagates to the + // checkpoint error handler (checkpoints stay enabled). + try { + for (const w of writes) { + await appendChange(globalStorageDir, task.taskId, { + path: w.path, + operation: w.operation, + checkpointId: result.commit, + ...(w.diffStats ? { diffStats: w.diffStats } : {}), + }) + } + } catch (err) { + console.error("[Task#checkpointSave] failed to append change journal entry", err) + } + } + } + return result + }) .catch((err) => { console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err) task.enableCheckpoints = false diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index f42a4ebf03..dccf51891f 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -21,6 +21,18 @@ interface ApplyPatchParams { patch: string } +/** + * B2: result of a single file operation within a patch. `succeeded` controls + * the whole-patch success state (and therefore the per-patch checkpoint), + * while `wrote` records whether the operation actually wrote a file — a no-op + * update must not produce a change-journal entry for a file that was never + * written. + */ +interface ApplyPatchFileOpResult { + succeeded: boolean + wrote: boolean +} + export class ApplyPatchTool extends BaseTool<"apply_patch"> { readonly name = "apply_patch" as const @@ -104,9 +116,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } // Process each file change. The handlers report whether their file - // operation succeeded, so a rejected approval or a failed local write - // does not get checkpointed as if the patch had succeeded. + // operation succeeded (which controls the whole-patch checkpoint) and + // whether it actually wrote a file (which controls the change journal + // — a no-op update must not be journaled). A rejected approval or a + // failed local write never gets checkpointed as a success. let patchSucceeded = true + const successfulChanges: ApplyPatchFileChange[] = [] for (const change of changes) { const relPath = change.path const absolutePath = path.resolve(task.cwd, relPath) @@ -116,7 +131,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!accessAllowed) { await task.say("rooignore_error", relPath) pushToolResult(formatResponse.rooIgnoreError(relPath)) - return + // B2 partial flush: break, not return - an earlier hunk may have + // already written a file, and those writes must still receive the + // checkpoint, journal entry, and change card. Failing the patch + // also keeps the consecutive-mistake counter from resetting. + patchSucceeded = false + break } // Check if file is write-protected @@ -124,37 +144,71 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - patchSucceeded = - (await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected)) && - patchSucceeded + const addResult = await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = addResult.succeeded && patchSucceeded + if (addResult.wrote) { + successfulChanges.push(change) + } } else if (change.type === "delete") { // Delete file - patchSucceeded = - (await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected)) && - patchSucceeded + const deleteResult = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + patchSucceeded = deleteResult.succeeded && patchSucceeded + if (deleteResult.wrote) { + successfulChanges.push(change) + } } else if (change.type === "update") { - // Update file - patchSucceeded = - (await this.handleUpdateFile( - change, - absolutePath, - relPath, - task, - callbacks, - isWriteProtected, - )) && patchSucceeded + // Update file (a no-op update succeeds without writing) + const updateResult = await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) + patchSucceeded = updateResult.succeeded && patchSucceeded + if (updateResult.wrote) { + successfulChanges.push(change) + } } } - task.consecutiveMistakeCount = 0 - - // B1: one checkpoint for the whole patch (not per file), and only when - // every file operation succeeded. Live setting with default-on - // semantics: skip only when explicitly false. + // Reset the consecutive-mistake counter only after a fully successful + // patch: a failed operation (missing file, rejected move, ...) increments + // the counter, and the count must survive a partially written patch so + // the auto-approval safety net still engages across consecutive failed + // patches. if (patchSucceeded) { + task.consecutiveMistakeCount = 0 + } + + // B1: one checkpoint for the whole patch (not per file). Live + // setting with default-on semantics: skip only when explicitly false. + // B3a partial flush: the checkpoint and journal are also taken when at + // least one file operation wrote, even if a later hunk of the same + // patch failed - the journal then documents exactly the subset that + // was written, and the failed operation was already reported through + // pushToolResult. A fully failed patch (nothing written) leaves no + // checkpoint behind. + if (patchSucceeded || successfulChanges.length > 0) { const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { - void checkpointSave(task, false, true).catch(() => {}) + // B2: one journal entry per file that was actually written by + // the patch (the simplest correct design for multi-file patches), + // all referencing the single checkpoint above. A no-op update + // contributes no entry because nothing was written. `movePath`, + // when present, is the file's final location. diffStats is + // omitted: the per-file approval diffs are computed inside the + // handlers and are not retained after the patch completes. + void checkpointSave( + task, + false, + true, + successfulChanges.map((change) => ({ + path: change.movePath ?? change.path, + operation: change.type === "add" ? "create" : change.type, + })), + ).catch(() => {}) } } } catch (error) { @@ -170,7 +224,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file already exists @@ -181,7 +235,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File already exists: ${relPath}. Use Update File instead.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { succeeded: false, wrote: false } } const newContent = change.newContent || "" @@ -235,7 +289,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Save the changes @@ -253,7 +307,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() - return true + return { succeeded: true, wrote: true } } private async handleDeleteFile( @@ -262,7 +316,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks // Check if file exists @@ -273,7 +327,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { succeeded: false, wrote: false } } const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) @@ -295,7 +349,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (!didApprove) { pushToolResult("Delete operation was rejected by the user.") - return false + return { succeeded: false, wrote: false } } // Delete the file @@ -305,13 +359,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { succeeded: false, wrote: false } } task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) task.processQueuedMessages() - return true + return { succeeded: true, wrote: true } } private async handleUpdateFile( @@ -321,9 +375,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, - ): Promise { + ): Promise { const { askApproval, pushToolResult } = callbacks + // A move reports failure when the original file cannot be deleted + // after the copy (both paths would remain on disk). + let moveSucceeded = true + // Check if file exists const fileExists = await fileExistsAtPath(absolutePath) if (!fileExists) { @@ -332,7 +390,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.` await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) - return false + return { succeeded: false, wrote: false } } const originalContent = change.originalContent || "" @@ -347,10 +405,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent) if (!diff) { // A no-op change is not a failure: the patch processed cleanly and - // nothing was written, so the whole-patch success state is kept. + // nothing was written, so the whole-patch success state is kept — + // but `wrote` stays false so the change journal does not document a + // write that never happened. pushToolResult(`No changes needed for '${relPath}'`) await task.diffViewProvider.reset() - return true + return { succeeded: true, wrote: false } } // Check experiment settings @@ -396,7 +456,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } pushToolResult("Changes were rejected by the user.") await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Handle file move if specified @@ -409,7 +469,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("rooignore_error", change.movePath) pushToolResult(formatResponse.rooIgnoreError(change.movePath)) await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Check if destination path is write-protected @@ -421,7 +481,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Check if destination path is outside workspace @@ -433,7 +493,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await task.say("error", errorMessage) pushToolResult(formatResponse.toolError(errorMessage)) await task.diffViewProvider.reset() - return false + return { succeeded: false, wrote: false } } // Save new content to the new path @@ -452,11 +512,19 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.writeFile(moveAbsolutePath, newContent, "utf8") } - // Delete the original file + // Delete the original file. A failed deletion leaves both paths on + // disk, so the move must be reported as a failure rather than + // checkpointed and journaled as a completed move. try { await fs.unlink(absolutePath) } catch (error) { + moveSucceeded = false console.error(`Failed to delete original file after move: ${error}`) + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Move of '${relPath}' to '${change.movePath}' failed: could not delete the original file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) } await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) @@ -477,7 +545,13 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { pushToolResult(message) await task.diffViewProvider.reset() task.processQueuedMessages() - return true + if (!moveSucceeded) { + // The destination file was written on disk before the source + // deletion failed, so the write must still be checkpointed and + // journaled; the move itself is reported as failed. + return { succeeded: false, wrote: true } + } + return { succeeded: true, wrote: true } } override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 0a3cb5d2e8..87a010e90c 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -466,7 +466,14 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) if (perWriteCheckpoints) { - void checkpointSave(task, false, true).catch(() => {}) + // B2: the change-journal entry for this edit is appended inside + // checkpointSave (the hook stays a single call site), keyed by the + // checkpoint commit that call produces. + void checkpointSave(task, false, true, { + path: relPath, + operation: isNewFile ? "create" : "update", + diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined, + }).catch(() => {}) } await task.diffViewProvider.reset() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index cf1500a510..5154af860d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -13,7 +13,7 @@ import { 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 { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff, type DiffStats } from "../diff/stats" import { checkpointSave } from "../checkpoints" import type { ToolUse } from "../../shared/tools" @@ -110,6 +110,10 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, ) + // B2: the approval-diff stats for the write, shared by both the + // approval message and the change-journal entry below. + let approvalDiffStats: DiffStats | null = null + if (isPreventFocusDisruptionEnabled) { task.diffViewProvider.editType = fileExists ? "modify" : "create" if (fileExists) { @@ -123,10 +127,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) + approvalDiffStats = computeDiffStats(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, - diffStats: computeDiffStats(unified) || undefined, + diffStats: approvalDiffStats || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -155,10 +160,11 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ? formatResponse.createPrettyPatch(relPath, task.diffViewProvider.originalContent, newContent) : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) + approvalDiffStats = computeDiffStats(unified) const completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, - diffStats: computeDiffStats(unified) || undefined, + diffStats: approvalDiffStats || undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) @@ -182,9 +188,18 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { pushToolResult(message) if (perWriteCheckpoints) { - // Await so the checkpoint (staging + commit) finishes before the next - // queued write starts; otherwise two writes can collapse into one commit. - await checkpointSave(task, false, true).catch(() => {}) + // B2: the change-journal entry for this write is appended inside + // checkpointSave (the hook stays a single call site), keyed by the + // checkpoint commit that call produces. Await so the checkpoint + // (staging + commit) finishes before the next queued write starts; + // otherwise two writes can collapse into one commit. + await checkpointSave(task, false, true, { + path: relPath, + operation: fileExists ? "update" : "create", + diffStats: approvalDiffStats + ? { additions: approvalDiffStats.added, deletions: approvalDiffStats.removed } + : undefined, + }).catch(() => {}) } await task.diffViewProvider.reset() diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index f5f64c939e..61ca2850dc 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -39,7 +39,10 @@ vi.mock("../../../utils/pathUtils", () => ({ })) vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(), checkpointSave: vi.fn().mockResolvedValue(undefined), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), })) describe("ApplyPatchTool.execute - delete file success path", () => { @@ -152,7 +155,11 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true) + // B2: the delete patch produces one journal write, referencing the + // single checkpoint saved for the whole patch. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete" }, + ]) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -358,6 +365,42 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + it("checkpoints the written subset when a later hunk is access-denied", async () => { + // Hunk 1 (src/first.ts) writes; hunk 2 (src/denied.ts) is rejected by + // validateAccess. The access-denied branch must not bypass the partial + // flush: the earlier write still receives the checkpoint/journal/card. + // Hunk 2's context matches the mocked file content so the patch + // passes pre-processing; the denial happens at the per-file access check. + const partialDenyPatch = `*** Begin Patch +*** Add File: src/first.ts ++hello +*** Update File: src/denied.ts +@@ +-original file content ++new content +*** End Patch` + const validateAccess = ( + mockTask["rooIgnoreController"] as unknown as { validateAccess: MockedFunction<() => boolean> } + ).validateAccess + validateAccess.mockReturnValueOnce(true).mockReturnValueOnce(false) + // The add target does not exist, so hunk 1 writes; fileExistsAtPath + // defaults to true and would otherwise reject the add. + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: partialDenyPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "src/denied.ts") + // Only the first (written) hunk is checkpointed. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, [ + expect.objectContaining({ path: "src/first.ts", operation: "create" }), + ]) + }) + it("does not record a checkpoint when the move destination is write-protected", async () => { // Source path check (execute loop) passes; the move destination fails. const isWriteProtected = ( @@ -396,6 +439,38 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + it("keeps the mistake count when a patch operation fails", async () => { + // Source path check (execute loop) passes; the move destination fails. + const isWriteProtected = ( + mockTask["rooProtectedController"] as unknown as { + isWriteProtected: MockedFunction<(p: string) => boolean> + } + ).isWriteProtected + isWriteProtected.mockReturnValueOnce(false).mockReturnValue(true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The failed operation incremented the counter; the end-of-loop reset + // must only run for a fully successful patch, so the count survives. + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("clears the mistake count after a fully successful patch", async () => { + mockTask.consecutiveMistakeCount = 2 + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) + it("records a checkpoint when the move succeeds", async () => { await tool.execute({ patch: movePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -423,5 +498,129 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockPushToolResult).toHaveBeenCalledWith("File saved successfully") expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) + + it("records one journal write per file change for a multi-file patch", async () => { + // src/a.ts does not exist (add); src/b.ts does (update). + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts"))) + const multiPatch = [ + "*** Begin Patch", + "*** Add File: src/a.ts", + "+alpha", + "*** Update File: src/b.ts", + "@@", + "-original file content", + "+second content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: multiPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/a.ts", operation: "create" }, + { path: "src/b.ts", operation: "update" }, + ]) + }) + + it("journals only the files actually written for a mixed no-op and write patch", async () => { + // src/same.ts exists and the hunk rewrites identical content (a + // no-op update); src/new.ts does not exist (a real write). + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(!String(filePath).toLowerCase().endsWith("new.ts")), + ) + const mixedPatch = [ + "*** Begin Patch", + "*** Update File: src/same.ts", + "@@", + "-original file content", + "+original file content", + "*** Add File: src/new.ts", + "+fresh content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: mixedPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The no-op update is reported to the model... + expect(mockPushToolResult).toHaveBeenCalledWith("No changes needed for 'src/same.ts'") + // ...but the journal documents only the file that was actually + // written, even though the whole patch succeeded. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/new.ts", operation: "create" }, + ]) + }) + + it("still checkpoints the successful subset when a later hunk fails", async () => { + // src/first.ts already exists (the add fails); src/second.ts does not + // (the add writes). The whole patch fails, but the written file is + // still documented by the checkpoint and journal. + mockedFileExistsAtPath.mockImplementation((filePath: string) => + Promise.resolve(String(filePath).toLowerCase().endsWith("first.ts")), + ) + const partialPatch = [ + "*** Begin Patch", + "*** Add File: src/first.ts", + "+boom", + "*** Add File: src/second.ts", + "+fresh", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: partialPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The failed operation is reported to the model... + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("File already exists")) + // ...and the successful subset is checkpointed and journaled. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/second.ts", operation: "create" }, + ]) + }) + + it("reports a failed move when the original file cannot be deleted", async () => { + mockedFsPromises.default.unlink.mockRejectedValueOnce(new Error("EBUSY: resource busy")) + const movePatch = [ + "*** Begin Patch", + "*** Update File: src/old.ts", + "*** Move to: src/new-location.ts", + "@@", + "-original file content", + "+new content", + "*** End Patch", + ].join(String.fromCharCode(10)) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The copy succeeded but the source still exists, so the move is + // reported as a failed tool error - but the destination write was + // made on disk and must still be covered by the checkpoint/journal. + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask as Task, + false, + true, + [expect.objectContaining({ path: "src/new-location.ts", operation: "update" })], + ) + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("could not delete the original file")) + }) }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 5b645a0074..bb309cf849 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -8,6 +8,7 @@ import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { checkpointSave } from "../../checkpoints" +import { computeDiffStats } from "../../diff/stats" import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ @@ -57,11 +58,16 @@ vi.mock("../../../utils/path", () => ({ vi.mock("../../diff/stats", () => ({ sanitizeUnifiedDiff: vi.fn((diff) => diff), - computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), + // The real computeDiffStats returns { added, removed } (DiffStats) — + // keep the mock faithful to the production shape. + computeDiffStats: vi.fn(() => ({ added: 1, removed: 1 })), })) vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(), checkpointSave: vi.fn().mockResolvedValue(undefined), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), })) vi.mock("vscode", () => ({ @@ -788,7 +794,14 @@ describe("editFileTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true) + // B2: the write info threads the path, operation, and the approval + // diff stats into the checkpoint hook. + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask, + false, + true, + { path: testFilePath, operation: "update", diffStats: { additions: 1, deletions: 1 } }, + ) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -815,5 +828,34 @@ describe("editFileTool", () => { expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error)) expect(mockedCheckpointSave).not.toHaveBeenCalled() }) + + it("records the checkpoint with a create operation for a new file", async () => { + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask, + false, + true, + { path: testFilePath, operation: "create", diffStats: { additions: 1, deletions: 1 } }, + ) + }) + + it("omits diff stats from the checkpoint write when the diff has no stats", async () => { + // A null approval diff produces no diffStats on the journal write. + vi.mocked(computeDiffStats).mockReturnValueOnce(null) + + await executeEditFileTool({}) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockTask, + false, + true, + { path: testFilePath, operation: "update" }, + ) + }) }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index f9286af36d..11224d77bc 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -9,6 +9,7 @@ import { unescapeHtmlEntities } from "../../../utils/text-normalization" import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from "../../../shared/tools" import { checkpointSave } from "../../checkpoints" +import { formatResponse } from "../../prompts/responses" import { writeToFileTool } from "../WriteToFileTool" vi.mock("path", async () => { @@ -161,6 +162,7 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ finalContent: "saved" }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -485,7 +487,14 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true) + // B2: the write info threads the path, operation, and the approval + // diff stats (3 added lines, 0 removed) into the checkpoint hook. + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockCline, + false, + true, + { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, + ) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -548,5 +557,49 @@ describe("writeToFileTool", () => { expect(settled).toBe(true) expect(processQueuedSpy).toHaveBeenCalledOnce() }) + + it("threads write info with approval diff stats when the prevent-focus-disruption experiment is enabled", async () => { + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + }), + }) + + await executeWriteFileTool({}) + + // The experiment branch saves directly (no diff view) and still + // journals the write through the same single checkpoint hook. + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + ) + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockCline, + false, + true, + { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, + ) + }) + + it("omits diff stats from the checkpoint write when the approval diff is empty", async () => { + // Writing identical content to an existing file produces an empty + // approval diff, so the checkpoint write carries no diffStats. + vi.mocked(formatResponse.createPrettyPatch).mockReturnValueOnce("") + + await executeWriteFileTool({}, { fileExists: true }) + + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(mockedCheckpointSave).toHaveBeenCalledWith( + mockCline, + false, + true, + { path: testFilePath, operation: "update" }, + ) + }) }) }) From 2500ab3e05fcb1ed020a3f70c2869ba3c14a8a3c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 01:26:27 +0800 Subject: [PATCH 25/46] feat(checkpoints): per-step change cards and changeCardDetail setting (B3a, #1375) --- packages/types/src/global-settings.ts | 16 ++ packages/types/src/message.ts | 45 ++++ packages/types/src/vscode-extension-host.ts | 3 +- .../checkpoints/__tests__/changeCard.spec.ts | 108 +++++++++ .../__tests__/checkpointJournal.test.ts | 14 +- .../__tests__/checkpointSave.spec.ts | 223 ++++++++++++++++++ src/core/checkpoints/changeCard.ts | 91 +++++++ src/core/checkpoints/index.ts | 27 ++- src/core/tools/ApplyPatchTool.ts | 88 ++++++- src/core/tools/EditFileTool.ts | 24 +- src/core/tools/WriteToFileTool.ts | 29 ++- .../__tests__/applyPatchTool.execute.spec.ts | 102 +++++++- src/core/tools/__tests__/editFileTool.spec.ts | 84 +++++-- .../tools/__tests__/writeToFileTool.spec.ts | 67 ++++-- src/core/tools/apply-patch/apply.ts | 9 + src/core/webview/ClineProvider.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 34 +++ .../src/context/ExtensionStateContext.tsx | 1 + .../__tests__/ExtensionStateContext.spec.tsx | 18 +- webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 37 files changed, 996 insertions(+), 63 deletions(-) create mode 100644 src/core/checkpoints/__tests__/changeCard.spec.ts create mode 100644 src/core/checkpoints/__tests__/checkpointSave.spec.ts create mode 100644 src/core/checkpoints/changeCard.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 4f21ca9607..3896c711c0 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -12,6 +12,7 @@ import { } from "./provider-settings.js" import { telemetrySettingsSchema } from "./telemetry.js" import { toolNamesSchema } from "./tool.js" +import { changeCardDetailSchema, type ChangeCardDetail } from "./message.js" import { type Keys } from "./type-fu.js" import { languagesSchema } from "./vscode.js" @@ -106,6 +107,14 @@ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 */ export const DEFAULT_PER_WRITE_CHECKPOINTS = true +/** + * Default detail level for per-step change cards (B3a). + * "summary" keeps cards compact (file list with +/− counts; the UI fetches + * diffs lazily); "full" carries the unified diff inline per file. + * @default "summary" + */ +export const DEFAULT_CHANGE_CARD_DETAIL: ChangeCardDetail = "summary" + /** * GlobalSettings */ @@ -213,6 +222,13 @@ export const globalSettingsSchema = z.object({ * @default true */ perWriteCheckpoints: z.boolean().optional(), + /** + * Detail level for per-step change cards: "full" includes the unified diff + * inline for every changed file, "summary" carries only the file list with + * +/− counts (diffs are fetched lazily by the UI). + * @default "summary" + */ + changeCardDetail: changeCardDetailSchema.optional(), ttsEnabled: z.boolean().optional(), ttsSpeed: z.number().optional(), diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 28d5af82ac..b7940d00a4 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -134,6 +134,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk { * - `mcp_server_response`: Response received from MCP server * - `subtask_result`: Result of a completed subtask * - `checkpoint_saved`: Indicates a checkpoint has been saved + * - `change_card`: Per-step change card summarizing the files a completed tool step wrote (B3a) * - `rooignore_error`: Error related to .rooignore file processing * - `diff_error`: Error occurred while applying a diff/patch * - `condense_context`: Context condensation/summarization has started @@ -162,6 +163,7 @@ export const clineSays = [ "mcp_server_response", "subtask_result", "checkpoint_saved", + "change_card", "rooignore_error", "diff_error", "condense_context", @@ -235,6 +237,49 @@ export const contextTruncationSchema = z.object({ export type ContextTruncation = z.infer +/** + * ChangeCard + * + * Payload of the per-step change card (B3a). The extension host emits one + * `say: "change_card"` message per completed tool write step, keyed by the + * shadow-git checkpoint the step produced. The JSON payload (see + * {@link ChangeCardData}) is carried in the message `text` field, the same + * way tool approval messages carry their serialized ClineSayTool. + * + * `detail: "full"` carries the unified diff inline for every file so the UI + * can render it directly; `detail: "summary"` carries only the file list with + * +/− counts and the UI fetches diffs lazily (B3b). Auto-approved steps are + * always emitted with `detail: "summary"` regardless of the user setting. + */ +export const changeCardDetailSchema = z.enum(["full", "summary"]) + +export type ChangeCardDetail = z.infer + +export const changeCardFileSchema = z.object({ + path: z.string(), + additions: z.number(), + deletions: z.number(), + /** + * Unified diff for this file. Only present when the card was emitted with + * `detail: "full"`; summary cards leave it out to stay compact. + */ + diff: z.string().optional(), +}) + +export type ChangeCardFile = z.infer + +export const changeCardSchema = z.object({ + /** Opaque step identifier, reserved for future tool-step tracking. */ + stepId: z.string().optional(), + /** Checkpoint commit SHAs produced by the step (one per per-write checkpoint). */ + checkpointIds: z.array(z.string()), + files: z.array(changeCardFileSchema), + totalFiles: z.number(), + detail: changeCardDetailSchema, +}) + +export type ChangeCardData = z.infer + /** * ClineMessage * diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 20756d7a68..9b43a75a1d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -5,7 +5,7 @@ import type { ProviderSettings, ProviderSettingsEntry } from "./provider-setting import type { HistoryItem } from "./history.js" import type { ModeConfig, PromptComponent } from "./mode.js" import type { Experiments } from "./experiment.js" -import type { ClineMessage, QueuedMessage } from "./message.js" +import type { ChangeCardDetail, ClineMessage, QueuedMessage } from "./message.js" import type { MarketplaceItem, MarketplaceInstalledMetadata, InstallMarketplaceItemOptions } from "./marketplace.js" import type { TodoItem } from "./todo.js" import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js" @@ -349,6 +349,7 @@ export type ExtensionState = Pick< enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) perWriteCheckpoints: boolean + changeCardDetail: ChangeCardDetail maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings diff --git a/src/core/checkpoints/__tests__/changeCard.spec.ts b/src/core/checkpoints/__tests__/changeCard.spec.ts new file mode 100644 index 0000000000..82190638a1 --- /dev/null +++ b/src/core/checkpoints/__tests__/changeCard.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest" + +import type { ChangeCardData } from "@roo-code/types" + +import { + buildChangeCard, + buildChangeCardPayload, + isAutoApprovedStep, + resolveChangeCardDetail, + type ChangeCardWrite, +} from "../changeCard" + +describe("changeCard (B3a)", () => { + function write(overrides: Partial = {}): ChangeCardWrite { + return { + path: "src/a.ts", + diffStats: { additions: 2, deletions: 1 }, + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2", + ...overrides, + } + } + + describe("isAutoApprovedStep", () => { + it("returns false for an empty step", () => { + expect(isAutoApprovedStep([])).toBe(false) + }) + + it("returns true only when every write was auto-approved", () => { + expect(isAutoApprovedStep([write({ autoApproved: true }), write({ autoApproved: true })])).toBe(true) + expect(isAutoApprovedStep([write({ autoApproved: true }), write()])).toBe(false) + expect(isAutoApprovedStep([write()])).toBe(false) + }) + }) + + describe("resolveChangeCardDetail", () => { + it("forces summary for auto-approved steps even when the setting is full", () => { + const writes = [write({ autoApproved: true })] + expect(resolveChangeCardDetail(writes, "full")).toBe("summary") + expect(resolveChangeCardDetail(writes, undefined)).toBe("summary") + }) + + it("follows the setting for interactive steps, defaulting to summary when unset", () => { + const writes = [write()] + expect(resolveChangeCardDetail(writes, "full")).toBe("full") + expect(resolveChangeCardDetail(writes, "summary")).toBe("summary") + expect(resolveChangeCardDetail(writes, undefined)).toBe("summary") + }) + }) + + describe("buildChangeCard", () => { + it("carries the inline diff per file for full detail on a multi-file step", () => { + const card = buildChangeCard( + "sha-1", + [write(), write({ path: "src/b.ts", diffStats: { additions: 1, deletions: 0 }, diff: "+b" })], + "full", + ) + + expect(card).toEqual({ + checkpointIds: ["sha-1"], + files: [ + { + path: "src/a.ts", + additions: 2, + deletions: 1, + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2", + }, + { path: "src/b.ts", additions: 1, deletions: 0, diff: "+b" }, + ], + totalFiles: 2, + detail: "full", + }) + }) + + it("omits the diff per file for summary detail (lazy fetch is B3b)", () => { + const card = buildChangeCard("sha-1", [write()], "summary") + + expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1 }]) + expect(card.files[0]).not.toHaveProperty("diff") + expect(card.detail).toBe("summary") + expect(card.totalFiles).toBe(1) + }) + + it("defaults missing diffStats to zero counts and keeps full detail without diff for a write without one", () => { + const card = buildChangeCard("sha-1", [write({ diffStats: undefined, diff: undefined })], "full") + + expect(card.files[0]).toEqual({ path: "src/a.ts", additions: 0, deletions: 0 }) + }) + }) + + describe("buildChangeCardPayload", () => { + it("resolves the detail level and builds the payload in one call", () => { + // The expectations are typed against the shared ChangeCardData + // contract in @roo-code/types, so the builder's output is checked + // against the same single source of truth the webview consumes. + // Interactive step with the full setting: diff inline. + const full: ChangeCardData = buildChangeCardPayload("sha-1", [write()], "full") + expect(full.detail).toBe("full") + expect(full.files[0].diff).toBe("--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1,2 @@\n-old\n+new-1\n+new-2") + + // Auto-approved step with the full setting: compact summary, no diff. + const compact: ChangeCardData = buildChangeCardPayload("sha-1", [write({ autoApproved: true })], "full") + expect(compact.detail).toBe("summary") + expect(compact.files[0]).not.toHaveProperty("diff") + expect(compact.checkpointIds).toEqual(["sha-1"]) + expect(compact.totalFiles).toBe(1) + }) + }) +}) diff --git a/src/core/checkpoints/__tests__/checkpointJournal.test.ts b/src/core/checkpoints/__tests__/checkpointJournal.test.ts index 5134c3a484..8671bb491c 100644 --- a/src/core/checkpoints/__tests__/checkpointJournal.test.ts +++ b/src/core/checkpoints/__tests__/checkpointJournal.test.ts @@ -66,6 +66,7 @@ interface ProviderLike { context: { globalStorageUri: { fsPath: string } } log: (...args: unknown[]) => void postMessageToWebview: (...args: unknown[]) => void + getState: () => Promise> } interface TaskLike { @@ -74,6 +75,7 @@ interface TaskLike { checkpointService: ServiceLike checkpointServiceInitializing: boolean providerRef: { deref: () => ProviderLike | undefined } + say: (...args: unknown[]) => Promise } describe("checkpointSave change-journal wiring (B2)", () => { @@ -81,7 +83,11 @@ describe("checkpointSave change-journal wiring (B2)", () => { let saveCheckpointSpy: Mock let mockProvider: ProviderLike let mockTask: TaskLike - const write: CheckpointWriteInfo = { path: "src/foo.ts", operation: "create", diffStats: { additions: 3, deletions: 0 } } + const write: CheckpointWriteInfo = { + path: "src/foo.ts", + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + } beforeEach(async () => { tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-wiring-")) @@ -90,6 +96,8 @@ describe("checkpointSave change-journal wiring (B2)", () => { context: { globalStorageUri: { fsPath: tmpStorageDir } }, log: vi.fn(), postMessageToWebview: vi.fn(), + // B3a: the card emission reads the live settings through getState. + getState: vi.fn().mockResolvedValue({}), } // Structural test double for Task (the class is not instantiated at // this unit layer); the cast is safe because the fields checkpointSave @@ -100,6 +108,10 @@ describe("checkpointSave change-journal wiring (B2)", () => { checkpointService: { isInitialized: true, saveCheckpoint: saveCheckpointSpy }, checkpointServiceInitializing: false, providerRef: { deref: () => mockProvider }, + // B3a: the card emission calls task.say; a resolved double keeps the + // test double complete instead of letting the emission take the + // error path. + say: vi.fn().mockResolvedValue(undefined), } }) diff --git a/src/core/checkpoints/__tests__/checkpointSave.spec.ts b/src/core/checkpoints/__tests__/checkpointSave.spec.ts new file mode 100644 index 0000000000..25afe4cad7 --- /dev/null +++ b/src/core/checkpoints/__tests__/checkpointSave.spec.ts @@ -0,0 +1,223 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import { journalPath } from "../changeJournal" +import { checkpointSave } from "../index" + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureCheckpointCreated: vi.fn(), + captureCheckpointRestored: vi.fn(), + captureCheckpointDiffed: vi.fn(), + }, + }, +})) + +/** + * Minimal Task double for the checkpointSave wiring tests. Only the members + * touched by getCheckpointService + checkpointSave are provided: a pre-set + * checkpointService (so no git installation happens), the provider context + * (journal dir + setting state), and say. Structural cast at the boundary, + * matching the documented test-double style of the tool specs. + */ +function makeTask(options: { saveCheckpoint?: unknown; state?: Record; enableCheckpoints?: boolean }) { + const say = vi.fn().mockResolvedValue(undefined) + // An explicit `saveCheckpoint: undefined` (a checkpoint that produced no + // commit) must be preserved as-is; only an omitted option falls back to + // the default commit result. + const saveCheckpoint = vi + .fn() + .mockResolvedValue("saveCheckpoint" in options ? options.saveCheckpoint : { commit: "sha-card-1" }) + const providerDeref = { + context: { globalStorageUri: { fsPath: globalStorageDir } }, + getState: vi.fn().mockResolvedValue(options.state ?? {}), + } + + const task = { + taskId: "task-card", + cwd: "/workspace", + enableCheckpoints: options.enableCheckpoints ?? true, + checkpointService: { + saveCheckpoint, + isInitialized: true, + }, + providerRef: { deref: vi.fn().mockReturnValue(providerDeref) }, + say, + } as unknown as Task + + return { task, say, saveCheckpoint } +} + +let globalStorageDir: string + +beforeEach(async () => { + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-save-")) +}) + +afterEach(async () => { + await fs.rm(globalStorageDir, { recursive: true, force: true }) +}) + +describe("checkpointSave change-card emission (B3a)", () => { + it("emits a full-detail change card after a successful per-write checkpoint and still appends the journal", async () => { + const { task, say, saveCheckpoint } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 2, deletions: 1 }, + diff: "+line-a\n+line-b\n-old", + }) + + expect(saveCheckpoint).toHaveBeenCalledWith(expect.stringContaining("task-card"), expect.any(Object)) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(1) + const [type, text, images, partial, sayOptions, _progress, options] = cardCalls[0] as unknown as [ + string, + string, + undefined, + undefined, + undefined, + undefined, + { isNonInteractive?: boolean }, + ] + expect(type).toBe("change_card") + expect(images).toBeUndefined() + expect(options).toEqual({ isNonInteractive: true }) + const card = JSON.parse(text as string) as { + checkpointIds: string[] + files: Array<{ path: string; additions: number; deletions: number; diff?: string }> + totalFiles: number + detail: string + } + expect(card.checkpointIds).toEqual(["sha-card-1"]) + expect(card.totalFiles).toBe(1) + expect(card.detail).toBe("full") + expect(card.files).toEqual([{ path: "src/a.ts", additions: 2, deletions: 1, diff: "+line-a\n+line-b\n-old" }]) + + // B2 regression: the journal entry is still appended with the commit id. + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + const entries = journalRaw + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ path: "src/a.ts", operation: "create", checkpointId: "sha-card-1" }) + }) + + it("emits a summary card without diffs for the default (summary) setting", async () => { + const { task, say } = makeTask({}) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "update", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + }) + + const cardCall = say.mock.calls.find(([type]) => type === "change_card") + expect(cardCall).toBeDefined() + const card = JSON.parse((cardCall as unknown as [string, string])[1]) as { + files: Array> + detail: string + } + expect(card.detail).toBe("summary") + expect(card.files[0]).not.toHaveProperty("diff") + }) + + it("emits a compact card for auto-approved steps even when the setting is full", async () => { + const { task, say } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + autoApproved: true, + }) + + const cardCall = say.mock.calls.find(([type]) => type === "change_card") + expect(cardCall).toBeDefined() + const card = JSON.parse((cardCall as unknown as [string, string])[1]) as { + files: Array> + detail: string + } + expect(card.detail).toBe("summary") + expect(card.files[0]).not.toHaveProperty("diff") + }) + + it("emits one card with all writes for a multi-file step", async () => { + const { task, say } = makeTask({ state: { changeCardDetail: "full" } }) + + await checkpointSave(task, false, true, [ + { path: "src/a.ts", operation: "create", diffStats: { additions: 2, deletions: 0 }, diff: "+a1\n+a2" }, + { path: "src/b.ts", operation: "delete", diffStats: { additions: 0, deletions: 3 } }, + ]) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(1) + const card = JSON.parse((cardCalls[0] as unknown as [string, string])[1]) as { + files: Array<{ path: string; diff?: string }> + totalFiles: number + } + expect(card.totalFiles).toBe(2) + expect(card.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]) + expect(card.files[0].diff).toBe("+a1\n+a2") + expect(card.files[1]).not.toHaveProperty("diff") + }) + + it("emits no change card for baseline checkpoints without write info", async () => { + const { task, say } = makeTask({}) + + await checkpointSave(task) + + // `say` is invoked with seven arguments, so a three-argument + // `toHaveBeenCalledWith` negative assertion can never fail; filter the + // recorded calls by type instead. + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("emits no change card when the checkpoint produced no commit", async () => { + const { task, say } = makeTask({ saveCheckpoint: undefined }) + + await checkpointSave(task, false, true, { path: "src/a.ts", operation: "create" }) + + const cardCalls = say.mock.calls.filter(([type]) => type === "change_card") + expect(cardCalls).toHaveLength(0) + }) + + it("emits no change card when checkpoints are disabled for the task", async () => { + const { task, say } = makeTask({ enableCheckpoints: false }) + + await checkpointSave(task, false, true, { path: "src/a.ts", operation: "create", diff: "+x" }) + + expect(say).not.toHaveBeenCalled() + }) + + it("keeps the journal append when a card emission failure occurs", async () => { + const { task, say } = makeTask({}) + say.mockImplementation(async (type: string) => { + if (type === "change_card") { + throw new Error("task aborted") + } + }) + + await checkpointSave(task, false, true, { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: "+x", + }) + + // The say failure is contained: the journal is still written. + const journalRaw = await fs.readFile(journalPath(globalStorageDir, "task-card"), "utf8") + expect(journalRaw).toContain("src/a.ts") + }) +}) diff --git a/src/core/checkpoints/changeCard.ts b/src/core/checkpoints/changeCard.ts new file mode 100644 index 0000000000..b13f03320d --- /dev/null +++ b/src/core/checkpoints/changeCard.ts @@ -0,0 +1,91 @@ +/** + * Per-step change card builder (B3a). + * + * The card is emitted from `checkpointSave` (index.ts) once the per-write + * checkpoint commit exists, so the payload can key the card by the real + * checkpoint ID and reuse the approval diff + stats the tool already + * computed (threaded through {@link CheckpointWriteInfo}). The card is + * informational and always emitted for write steps, including auto-approved + * ones — which always get the compact ("summary") form regardless of the + * `changeCardDetail` setting. + */ +import { DEFAULT_CHANGE_CARD_DETAIL, type ChangeCardData, type ChangeCardDetail } from "@roo-code/types" + +/** + * The write data a change card is built from. Structurally compatible with + * `CheckpointWriteInfo` (src/core/checkpoints/index.ts), minus the + * `operation` field the card does not need. + */ +export interface ChangeCardWrite { + /** The file path as the tool knows it (relative to the task cwd). */ + path: string + /** { additions, deletions } from the approval diff, when computable. */ + diffStats?: { additions: number; deletions: number } + /** The unified approval diff for this file (reused, not recomputed). */ + diff?: string + /** Whether the tool step was auto-approved (no human interaction). */ + autoApproved?: boolean +} + +/** + * Whether every write of the step was auto-approved. Empty steps are not + * auto-approved (there is nothing for the user to have skipped). + */ +export function isAutoApprovedStep(writes: readonly ChangeCardWrite[]): boolean { + return writes.length > 0 && writes.every((write) => write.autoApproved === true) +} + +/** + * Resolve the card detail level for a step: + * - auto-approved steps always get the compact "summary" card, regardless of + * the user setting (cards for steps the user never saw approving are + * informational only); + * - otherwise the `changeCardDetail` setting applies, defaulting to + * "summary" when unset. + */ +export function resolveChangeCardDetail( + writes: readonly ChangeCardWrite[], + setting: ChangeCardDetail | undefined, +): ChangeCardDetail { + if (isAutoApprovedStep(writes)) { + return "summary" + } + return setting ?? DEFAULT_CHANGE_CARD_DETAIL +} + +/** + * Build the typed change-card payload for one step (one per-write checkpoint). + * + * With `detail: "full"` each file carries its unified diff inline; with + * `detail: "summary"` the diff is omitted and the UI fetches it lazily + * (B3b). + */ +export function buildChangeCard( + checkpointId: string, + writes: readonly ChangeCardWrite[], + detail: ChangeCardDetail, +): ChangeCardData { + return { + checkpointIds: [checkpointId], + files: writes.map((write) => ({ + path: write.path, + additions: write.diffStats?.additions ?? 0, + deletions: write.diffStats?.deletions ?? 0, + ...(detail === "full" && write.diff ? { diff: write.diff } : {}), + })), + totalFiles: writes.length, + detail, + } +} + +/** + * Convenience wrapper: resolve the detail level from the step + setting, then + * build the payload. This is what `checkpointSave` calls. + */ +export function buildChangeCardPayload( + checkpointId: string, + writes: readonly ChangeCardWrite[], + setting: ChangeCardDetail | undefined, +): ChangeCardData { + return buildChangeCard(checkpointId, writes, resolveChangeCardDetail(writes, setting)) +} diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index bcfafb5cd6..d206767ad4 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -17,6 +17,7 @@ import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints" import { appendChange, ChangeJournalEntry } from "./changeJournal" +import { buildChangeCardPayload } from "./changeCard" const WARNING_THRESHOLD_MS = 5000 @@ -225,6 +226,16 @@ export type CheckpointWriteInfo = { path: string operation: "create" | "update" | "delete" diffStats?: { additions: number; deletions: number } + /** + * The unified approval diff for this write, reused verbatim by the B3a + * change card (never recomputed). + */ + diff?: string + /** + * Whether the tool step was auto-approved (no human interaction). Auto- + * approved steps always get the compact ("summary") change card. + */ + autoApproved?: boolean } export async function checkpointSave( @@ -250,9 +261,9 @@ export async function checkpointSave( // resolves to undefined / rejects), and non-write checkpoint calls // (e.g. the task-start baseline) pass no `write` value at all. if (result?.commit && write) { + const writes = Array.isArray(write) ? write : [write] const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath if (globalStorageDir) { - const writes = Array.isArray(write) ? write : [write] // Append sequentially so journal lines preserve write order. A // journal failure is logged here and never propagates to the // checkpoint error handler (checkpoints stay enabled). @@ -269,6 +280,20 @@ export async function checkpointSave( console.error("[Task#checkpointSave] failed to append change journal entry", err) } } + + // B3a: emit the per-step change card now that the checkpoint commit + // exists. The card reuses the approval diff/stats the tool already + // computed and is always emitted (auto-approved steps included); + // a card failure is logged and never disables checkpoints. + try { + const state = await task.providerRef.deref()?.getState() + const card = buildChangeCardPayload(result.commit, writes, state?.changeCardDetail) + await task.say("change_card", JSON.stringify(card), undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + } catch (err) { + console.error("[Task#checkpointSave] failed to emit change card", err) + } } return result }) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index dccf51891f..bba826ea4d 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -7,6 +7,7 @@ import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" import { checkpointSave } from "../checkpoints" +import { checkAutoApproval } from "../auto-approval" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" @@ -144,14 +145,28 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - const addResult = await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + const addResult = await this.handleAddFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) patchSucceeded = addResult.succeeded && patchSucceeded if (addResult.wrote) { successfulChanges.push(change) } } else if (change.type === "delete") { // Delete file - const deleteResult = await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + const deleteResult = await this.handleDeleteFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + ) patchSucceeded = deleteResult.succeeded && patchSucceeded if (deleteResult.wrote) { successfulChanges.push(change) @@ -194,21 +209,34 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const perWriteCheckpoints = (await task.providerRef?.deref()?.getState())?.perWriteCheckpoints if (perWriteCheckpoints !== false) { // B2: one journal entry per file that was actually written by - // the patch (the simplest correct design for multi-file patches), + // the patch (the simplest correct design for multi-file patches), // all referencing the single checkpoint above. A no-op update // contributes no entry because nothing was written. `movePath`, - // when present, is the file's final location. diffStats is - // omitted: the per-file approval diffs are computed inside the - // handlers and are not retained after the patch completes. - void checkpointSave( + // when present, is the file's final location. B3a: the per-file + // approval diff/stats and auto-approval state, retained by the + // handlers, feed the per-step change card. + // Awaited: a later write must not interleave with this patch's + // staging/commit/journal/change-card work. checkpointSave never + // rejects (service call wrapped in try/catch upstream). + await checkpointSave( task, false, true, successfulChanges.map((change) => ({ path: change.movePath ?? change.path, operation: change.type === "add" ? "create" : change.type, + ...(change.diffStats + ? { + diffStats: { + additions: change.diffStats.added, + deletions: change.diffStats.removed, + }, + } + : {}), + ...(change.diff ? { diff: change.diff } : {}), + ...(change.autoApproved ? { autoApproved: true } : {}), })), - ).catch(() => {}) + ) } } } catch (error) { @@ -274,6 +302,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + // B3a: retain the approval diff/stats and auto-approval state so the + // post-loop checkpoint hook can build the per-step change card. + change.diff = sanitizedDiff + change.diffStats = diffStats + change.autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) @@ -311,6 +354,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } private async handleDeleteFile( + change: ApplyPatchFileChange, absolutePath: string, relPath: string, task: Task, @@ -345,6 +389,19 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { isProtected: isWriteProtected, } satisfies ClineSayTool) + // B3a: auto-approval state feeds the per-step change card (deletes have + // no diff to thread). + change.autoApproved = + ( + await checkAutoApproval({ + state: await task.providerRef.deref()?.getState(), + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { @@ -441,6 +498,21 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { diffStats, } satisfies ClineSayTool) + // B3a: retain the approval diff/stats and auto-approval state so the + // post-loop checkpoint hook can build the per-step change card. + change.diff = sanitizedDiff + change.diffStats = diffStats + change.autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Show diff view if focus disruption prevention is disabled if (!isPreventFocusDisruptionEnabled) { await task.diffViewProvider.open(relPath) diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 87a010e90c..03c20ccee2 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -12,6 +12,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import { checkpointSave } from "../../core/checkpoints" +import { checkAutoApproval } from "../auto-approval" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -468,12 +469,29 @@ export class EditFileTool extends BaseTool<"edit_file"> { if (perWriteCheckpoints) { // B2: the change-journal entry for this edit is appended inside // checkpointSave (the hook stays a single call site), keyed by the - // checkpoint commit that call produces. - void checkpointSave(task, false, true, { + // checkpoint commit that call produces. B3a threads the approval + // diff (for the change card) and whether the step was auto- + // approved (auto-approved steps always get the compact card). + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + // Awaited: a later tool block must not interleave with this edit's + // staging/commit/journal/change-card work. checkpointSave never + // rejects (service call wrapped in try/catch upstream). + await checkpointSave(task, false, true, { path: relPath, operation: isNewFile ? "create" : "update", diffStats: diffStats ? { additions: diffStats.added, deletions: diffStats.removed } : undefined, - }).catch(() => {}) + ...(sanitizedDiff ? { diff: sanitizedDiff } : {}), + ...(autoApproved ? { autoApproved: true } : {}), + }) } await task.diffViewProvider.reset() diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 5154af860d..ea9aec48f1 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -15,6 +15,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff, type DiffStats } from "../diff/stats" import { checkpointSave } from "../checkpoints" +import { checkAutoApproval } from "../auto-approval" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -111,8 +112,12 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { ) // B2: the approval-diff stats for the write, shared by both the - // approval message and the change-journal entry below. + // approval message and the change-journal entry below. B3a also + // reuses the sanitized unified diff itself for the per-step change + // card (never recomputed). let approvalDiffStats: DiffStats | null = null + let approvalDiff = "" + let completeMessage = "" if (isPreventFocusDisruptionEnabled) { task.diffViewProvider.editType = fileExists ? "modify" : "create" @@ -128,7 +133,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) approvalDiffStats = computeDiffStats(unified) - const completeMessage = JSON.stringify({ + approvalDiff = unified + completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, diffStats: approvalDiffStats || undefined, @@ -161,7 +167,8 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { : convertNewFileToUnifiedDiff(newContent, relPath) unified = sanitizeUnifiedDiff(unified) approvalDiffStats = computeDiffStats(unified) - const completeMessage = JSON.stringify({ + approvalDiff = unified + completeMessage = JSON.stringify({ ...sharedMessageProps, content: unified, diffStats: approvalDiffStats || undefined, @@ -192,13 +199,27 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { // checkpointSave (the hook stays a single call site), keyed by the // checkpoint commit that call produces. Await so the checkpoint // (staging + commit) finishes before the next queued write starts; - // otherwise two writes can collapse into one commit. + // otherwise two writes can collapse into one commit. B3a threads + // the approval diff (for the change card) and whether the step was + // auto-approved (auto-approved steps always get the compact card). + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" await checkpointSave(task, false, true, { path: relPath, operation: fileExists ? "update" : "create", diffStats: approvalDiffStats ? { additions: approvalDiffStats.added, deletions: approvalDiffStats.removed } : undefined, + ...(approvalDiff ? { diff: approvalDiff } : {}), + ...(autoApproved ? { autoApproved: true } : {}), }).catch(() => {}) } diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 61ca2850dc..10c90d36a6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -488,6 +488,31 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockedCheckpointSave).toHaveBeenCalledOnce() }) + it("awaits the patch checkpoint before execute settles", async () => { + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveDeferred = new Promise((resolve) => (resolveSave = resolve)) + mockedCheckpointSave.mockImplementationOnce(() => saveDeferred) + + const executePromise = tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // execute must not settle while the checkpoint is still in flight: + // a later write would otherwise interleave with this patch's staged work. + let settled = false + void executePromise.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + resolveSave() + await executePromise + expect(settled).toBe(true) + }) + it("records a checkpoint when the in-place update succeeds", async () => { await tool.execute({ patch: updatePatch }, mockTask as Task, { askApproval: mockAskApproval, @@ -502,7 +527,8 @@ describe("ApplyPatchTool.execute - delete file success path", () => { it("records one journal write per file change for a multi-file patch", async () => { // src/a.ts does not exist (add); src/b.ts does (update). mockedFileExistsAtPath.mockImplementation((filePath: string) => - Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts"))) + Promise.resolve(!String(filePath).toLowerCase().endsWith("a.ts")), + ) const multiPatch = [ "*** Begin Patch", "*** Add File: src/a.ts", @@ -521,9 +547,61 @@ describe("ApplyPatchTool.execute - delete file success path", () => { }) expect(mockedCheckpointSave).toHaveBeenCalledOnce() + // B3a: each write threads the approval diff and stats computed by its + // handler so the per-step change card can reuse them verbatim. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { + path: "src/a.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+alpha"), + }, + { + path: "src/b.ts", + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: expect.stringContaining("+second content"), + }, + ]) + }) + }) + + describe("change-card threading (B3a)", () => { + const mockedCheckpointSave = checkpointSave as MockedFunction + const deletePatch = `*** Begin Patch + *** Delete File: src/obsolete.ts + *** End Patch` + + it("threads autoApproved into the checkpoint writes for auto-approved steps", async () => { + // B3a: auto-approved steps carry autoApproved on every write so + // checkpointSave can force the compact (summary) change card. + const ref = (mockTask["providerRef"] as unknown as { deref: MockedFunction<() => unknown> }).deref + ref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ autoApprovalEnabled: true, alwaysAllowWrite: true }), + }) + + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ - { path: "src/a.ts", operation: "create" }, - { path: "src/b.ts", operation: "update" }, + { path: "src/obsolete.ts", operation: "delete", autoApproved: true }, + ]) + }) + + it("omits autoApproved when the step is not auto-approved", async () => { + // The default provider state ({}) disables auto-approval, so no write + // carries the autoApproved flag and the card follows the user setting. + await tool.execute({ patch: deletePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ + { path: "src/obsolete.ts", operation: "delete" }, ]) }) @@ -556,7 +634,12 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // written, even though the whole patch succeeded. expect(mockedCheckpointSave).toHaveBeenCalledOnce() expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ - { path: "src/new.ts", operation: "create" }, + { + path: "src/new.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+fresh content"), + }, ]) }) @@ -587,7 +670,12 @@ describe("ApplyPatchTool.execute - delete file success path", () => { // ...and the successful subset is checkpointed and journaled. expect(mockedCheckpointSave).toHaveBeenCalledOnce() expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask as Task, false, true, [ - { path: "src/second.ts", operation: "create" }, + { + path: "src/second.ts", + operation: "create", + diffStats: { additions: 1, deletions: 0 }, + diff: expect.stringContaining("+fresh"), + }, ]) }) @@ -620,7 +708,9 @@ describe("ApplyPatchTool.execute - delete file success path", () => { [expect.objectContaining({ path: "src/new-location.ts", operation: "update" })], ) expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") - expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("could not delete the original file")) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("could not delete the original file"), + ) }) }) }) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index bb309cf849..2f975966b0 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -795,13 +795,14 @@ describe("editFileTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() // B2: the write info threads the path, operation, and the approval - // diff stats into the checkpoint hook. - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask, - false, - true, - { path: testFilePath, operation: "update", diffStats: { additions: 1, deletions: 1 } }, - ) + // diff stats into the checkpoint hook. B3a: the approval diff itself is + // threaded verbatim for the per-step change card. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -834,28 +835,73 @@ describe("editFileTool", () => { expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask, - false, - true, - { path: testFilePath, operation: "create", diffStats: { additions: 1, deletions: 1 } }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) }) it("omits diff stats from the checkpoint write when the diff has no stats", async () => { // A null approval diff produces no diffStats on the journal write. + // The diff itself is still threaded for the change card (B3a). vi.mocked(computeDiffStats).mockReturnValueOnce(null) await executeEditFileTool({}) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockTask, - false, - true, - { path: testFilePath, operation: "update" }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diff: "mock-diff", + }) + }) + + it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { + // B3a: when the step is auto-approved the checkpoint write carries + // autoApproved so checkpointSave can force the compact change card. + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + autoApprovalEnabled: true, + alwaysAllowWrite: true, + }), + }) + + await executeEditFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: testFilePath, + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + autoApproved: true, + }) + }) + + it("awaits the edit checkpoint before execute settles", async () => { + type SaveResult = Awaited> + let resolveSave: (value: SaveResult | PromiseLike) => void = () => {} + const saveDeferred = new Promise((resolve) => (resolveSave = resolve)) + mockedCheckpointSave.mockImplementationOnce(() => saveDeferred) + + const executePromise = executeEditFileTool({}) + + // execute must not settle while the checkpoint is still in flight: + // a later tool block would otherwise interleave with this edit's staged work. + let settled = false + void executePromise.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockedCheckpointSave).toHaveBeenCalledOnce() + expect(settled).toBe(false) + + resolveSave() + await executePromise + expect(settled).toBe(true) }) }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 11224d77bc..cc2446288f 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -11,6 +11,7 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { checkpointSave } from "../../checkpoints" import { formatResponse } from "../../prompts/responses" import { writeToFileTool } from "../WriteToFileTool" +import { convertNewFileToUnifiedDiff, sanitizeUnifiedDiff } from "../../diff/stats" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -102,6 +103,10 @@ describe("writeToFileTool", () => { const testContent = "Line 1\nLine 2\nLine 3" const testContentWithMarkdown = "```javascript\nLine 1\nLine 2\n```" + // The exact approval diff the tool computes for a new file (B3a threads it + // into the checkpoint write for the per-step change card). + const newFileApprovalDiff = sanitizeUnifiedDiff(convertNewFileToUnifiedDiff(testContent, testFilePath)) + // Mocked functions with correct types const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction @@ -489,12 +494,14 @@ describe("writeToFileTool", () => { expect(mockedCheckpointSave).toHaveBeenCalledOnce() // B2: the write info threads the path, operation, and the approval // diff stats (3 added lines, 0 removed) into the checkpoint hook. - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockCline, - false, - true, - { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, - ) + // B3a: the approval diff itself is threaded verbatim for the + // per-step change card. + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + }) }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { @@ -570,7 +577,8 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}) // The experiment branch saves directly (no diff view) and still - // journals the write through the same single checkpoint hook. + // journals the write through the same single checkpoint hook, carrying + // the approval diff for the per-step change card (B3a). expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( testFilePath, testContent, @@ -578,12 +586,12 @@ describe("writeToFileTool", () => { true, 1000, ) - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockCline, - false, - true, - { path: testFilePath, operation: "create", diffStats: { additions: 3, deletions: 0 } }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + }) }) it("omits diff stats from the checkpoint write when the approval diff is empty", async () => { @@ -594,12 +602,33 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: true }) expect(mockedCheckpointSave).toHaveBeenCalledOnce() - expect(mockedCheckpointSave).toHaveBeenCalledWith( - mockCline, - false, - true, - { path: testFilePath, operation: "update" }, - ) + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "update", + }) + }) + + it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { + // B3a: when the step is auto-approved the checkpoint write carries + // autoApproved so checkpointSave can force the compact change card. + mockCline.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + autoApprovalEnabled: true, + alwaysAllowWrite: true, + }), + }) + + await executeWriteFileTool({}) + + expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { + path: testFilePath, + operation: "create", + diffStats: { additions: 3, deletions: 0 }, + diff: newFileApprovalDiff, + autoApproved: true, + }) }) }) }) diff --git a/src/core/tools/apply-patch/apply.ts b/src/core/tools/apply-patch/apply.ts index 4ab377f732..64009d27bf 100644 --- a/src/core/tools/apply-patch/apply.ts +++ b/src/core/tools/apply-patch/apply.ts @@ -29,6 +29,15 @@ export interface ApplyPatchFileChange { originalContent?: string /** New content (for add/update) */ newContent?: string + /** + * B3a: the unified approval diff for this file (computed by the tool + * handler), reused by the per-step change card. + */ + diff?: string + /** B3a: { added, removed } stats of the approval diff, when computable. */ + diffStats?: { added: number; removed: number } + /** B3a: whether this file's approval was auto-approved. */ + autoApproved?: boolean } /** diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 481904ed57..52bb209922 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -52,6 +52,7 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_PER_WRITE_CHECKPOINTS, + DEFAULT_CHANGE_CARD_DETAIL, getModelId, isRetiredProvider, providerIdentifiers, @@ -2558,6 +2559,7 @@ export class ClineProvider enableCheckpoints, checkpointTimeout, perWriteCheckpoints, + changeCardDetail, soundVolume, writeDelayMs, diffFuzzyThreshold, @@ -2718,6 +2720,7 @@ export class ClineProvider enableCheckpoints: enableCheckpoints ?? true, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, allowedCommands: mergedAllowedCommands, @@ -2955,6 +2958,7 @@ export class ClineProvider enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, perWriteCheckpoints: stateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: stateValues.changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7ec6bb782b..caf6bfd002 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -721,6 +721,7 @@ describe("ClineProvider", () => { ttsEnabled: false, enableCheckpoints: false, perWriteCheckpoints: false, + changeCardDetail: "summary", writeDelayMs: 1000, mcpEnabled: true, mode: defaultModeSlug, @@ -1444,6 +1445,39 @@ describe("ClineProvider", () => { expect(state.perWriteCheckpoints).toBe(true) }) + test("getState returns the saved changeCardDetail setting", async () => { + await provider.contextProxy.setValue("changeCardDetail", "full") + + const state = await provider.getState() + + expect(state.changeCardDetail).toBe("full") + }) + + test("getState defaults changeCardDetail to summary when unset", async () => { + const state = await provider.getState() + + expect(state.changeCardDetail).toBe("summary") + }) + + test("getStateToPostToWebview returns the saved changeCardDetail setting", async () => { + // The default is "summary", so only an explicit "full" proves that the + // stored value (rather than the default) reaches the webview state. + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("changeCardDetail", "full") + + const state = await provider.getStateToPostToWebview() + + expect(state.changeCardDetail).toBe("full") + }) + + test("getStateToPostToWebview defaults changeCardDetail to summary when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const state = await provider.getStateToPostToWebview() + + expect(state.changeCardDetail).toBe("summary") + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 203594275d..92c3e5f553 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -212,6 +212,7 @@ const createInitialExtensionState = (): ExtensionState => ({ ttsSpeed: 1.0, enableCheckpoints: true, perWriteCheckpoints: true, + changeCardDetail: "summary", checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds language: "en", // Default language code writeDelayMs: 1000, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index f0655b55e0..38d3b00f0b 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -408,6 +408,7 @@ describe("mergeExtensionState", () => { shouldShowAnnouncement: false, enableCheckpoints: true, perWriteCheckpoints: true, + changeCardDetail: "summary", writeDelayMs: 1000, mode: "default", experiments: {} as Record, @@ -438,12 +439,16 @@ describe("mergeExtensionState", () => { const prevState: ExtensionState = { ...baseState, + // Non-default checkpoint keys so a merge regression that drops or + // resets them cannot hide behind the initial defaults. + perWriteCheckpoints: false, + changeCardDetail: "full", apiConfiguration: { modelMaxTokens: 1234, modelMaxThinkingTokens: 123 }, experiments: {} as Record, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS - 5, } - const newState: ExtensionState = { + const newState: Partial = { ...baseState, apiConfiguration: { modelMaxThinkingTokens: 456, modelTemperature: 0.3 }, experiments: { @@ -455,6 +460,11 @@ describe("mergeExtensionState", () => { checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS + 5, } + // A partial state push may omit the checkpoint keys entirely; the + // merge must preserve the previous non-default values. + delete newState.perWriteCheckpoints + delete newState.changeCardDetail + const result = mergeExtensionState(prevState, newState) expect(result.apiConfiguration).toEqual({ @@ -468,6 +478,11 @@ describe("mergeExtensionState", () => { runSlashCommand: false, customTools: false, }) + + // A partial push that omits the checkpoint keys must keep the previous + // non-default values. + expect(result.perWriteCheckpoints).toBe(false) + expect(result.changeCardDetail).toBe("full") }) describe("clineMessagesSeq protection", () => { @@ -479,6 +494,7 @@ describe("mergeExtensionState", () => { shouldShowAnnouncement: false, enableCheckpoints: true, perWriteCheckpoints: true, + changeCardDetail: "summary", writeDelayMs: 1000, mode: "default", experiments: {} as Record, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index dca198cc7b..6016f2a274 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Punt de control després de cada escriptura de fitxer", "description": "Registra una instantània de punt de control després de cada escriptura de fitxer reeixida de l’agent" + }, + "changeCardDetail": { + "label": "Mostra la diff completa a les targetes de canvis", + "description": "Inclou la diff unificada completa en línia per a cada fitxer a les targetes de canvis per pas. Desactivada, les targetes només mostren la llista de fitxers amb les línies afegides/eliminades." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 530c799733..73d7018e45 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Kontrollpunkt nach jedem Dateischreibvorgang", "description": "Ein Kontrollpunkt-Snapshot wird nach jedem erfolgreichen Dateischreibvorgang des Agents erfasst" + }, + "changeCardDetail": { + "label": "Volle Diff in Änderungskarten anzeigen", + "description": "Enthält die vollständige Unified-Diff inline für jede Datei in den Änderungskarten pro Schritt. Deaktiviert zeigen die Karten nur die Dateiliste mit hinzugefügten/entfernten Zeilen." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 5f8b40ade2..7528ded35e 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -785,6 +785,10 @@ "perWrite": { "label": "Checkpoint after each file write", "description": "Record a checkpoint snapshot after every successful file write by the agent" + }, + "changeCardDetail": { + "label": "Show full diff in change cards", + "description": "Include the full unified diff inline for every file in per-step change cards. When off, cards show only the file list with added/removed line counts." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 704f8d1d31..983380ae8a 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Punto de control después de cada escritura de archivo", "description": "Registra una instantánea de punto de control después de cada escritura de archivo exitosa del agente" + }, + "changeCardDetail": { + "label": "Mostrar la diff completa en las tarjetas de cambios", + "description": "Incluye la diff unificada completa en línea para cada archivo en las tarjetas de cambios por paso. Al desactivarla, las tarjetas muestran solo la lista de archivos con las líneas añadidas/eliminadas." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 5db4e0dfe7..2548f3ea1e 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Point de contrôle après chaque écriture de fichier", "description": "Enregistre un instantané de point de contrôle après chaque écriture de fichier réussie par l’agent" + }, + "changeCardDetail": { + "label": "Afficher la diff complète dans les cartes de modifications", + "description": "Inclut la diff unifiée complète en ligne pour chaque fichier dans les cartes de modifications par étape. Désactivée, les cartes n'affichent que la liste des fichiers avec les lignes ajoutées/retirées." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 4570f85117..14008fc161 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "हर फ़ाइल लिखने के बाद चेकपॉइंट", "description": "एजेंट द्वारा हर सफल फ़ाइल लिखने के बाद एक चेकपॉइंट स्नैपशॉट दर्ज किया जाता है" + }, + "changeCardDetail": { + "label": "बदलाव कार्डों में पूर्ण diff दिखाएँ", + "description": "प्रति-चरण बदलाव कार्डों में हर फ़ाइल के लिए पूर्ण unified diff इनलाइन शामिल करता है। बंद होने पर कार्ड केवल जोड़ी/हटाई गई पंक्तियों के साथ फ़ाइल सूची दिखाते हैं।" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 2918f14870..945fbdc741 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Checkpoint setelah setiap penulisan file", "description": "Merekam snapshot checkpoint setelah setiap penulisan file yang berhasil oleh agen" + }, + "changeCardDetail": { + "label": "Tampilkan diff lengkap di kartu perubahan", + "description": "Termasuk diff terpadu lengkap secara inline untuk setiap file di kartu perubahan per langkah. Saat dimatikan, kartu hanya menampilkan daftar file dengan baris yang ditambahkan/dihapus." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 6909e6a1a2..f638df43ca 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Punto di controllo dopo ogni scrittura del file", "description": "Registra uno snapshot di punto di controllo dopo ogni scrittura del file riuscita dell’agente" + }, + "changeCardDetail": { + "label": "Mostra la diff completa nelle card dei cambiamenti", + "description": "Include la diff unificata completa in linea per ogni file nelle card dei cambiamenti per passo. Se disattivata, le card mostrano solo l'elenco dei file con le righe aggiunte/rimosse." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 6deaeca4d6..d917add07d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "ファイルの書き込みごとにチェックポイント", "description": "エージェントによる各ファイルの書き込み成功後にチェックポイントのスナップショットを記録します" + }, + "changeCardDetail": { + "label": "変更カードに完全な diff を表示", + "description": "ステップごとの変更カードに各ファイルの完全な unified diff をインラインで含めます。オフにすると、カードは追加/削除行数付きのファイル一覧のみを表示します。" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index a6c6583347..79c1e843f2 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "파일을 쓸 때마다 체크포인트", "description": "에이전트가 파일 쓰기에 성공할 때마다 체크포인트 스냅샷을 기록합니다" + }, + "changeCardDetail": { + "label": "변경 카드에 전체 diff 표시", + "description": "단계별 변경 카드에 각 파일의 전체 unified diff를 인라인으로 포함합니다. 끄면 카드는 추가/제거 줄 수만 있는 파일 목록만 표시합니다." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 77f6f5797b..8295b30ce7 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Checkpoint na elke bestandsschrijving", "description": "Neemt een checkpoint-snapshot op na elke succesvolle bestandsschrijving door de agent" + }, + "changeCardDetail": { + "label": "Volledige diff tonen in wijzigingskaarten", + "description": "Bevat de volledige unified diff inline voor elk bestand in wijzigingskaarten per stap. Bij uitgeschakeld tonen de kaarten alleen de bestandslijst met toegevoegde/verwijderde regels." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 49b341cb9f..fc6407faef 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Punkt kontrolny po każdym zapisaniu pliku", "description": "Rejestruje migawkę punktu kontrolnego po każdym udanym zapisaniu pliku przez agenta" + }, + "changeCardDetail": { + "label": "Pokaż pełny diff w kartach zmian", + "description": "Zawiera pełny spójny diff inline dla każdego pliku w kartach zmian per krok. Po wyłączeniu karty pokazują tylko listę plików z dodanymi/usuniętymi liniami." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 71694fe203..06dd7b5ee2 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Ponto de verificação após cada gravação de arquivo", "description": "Registra um snapshot de ponto de verificação após cada gravação de arquivo bem-sucedida pelo agente" + }, + "changeCardDetail": { + "label": "Mostrar diff completo nos cartões de alterações", + "description": "Inclui a diff unificada completa em linha para cada arquivo nos cartões de alterações por etapa. Quando desativado, os cartões mostram apenas a lista de arquivos com linhas adicionadas/removidas." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index e9a3f5938b..4e72a14bcc 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Контрольная точка после каждой записи файла", "description": "Записывает снимок контрольной точки после каждой успешной записи файла агентом" + }, + "changeCardDetail": { + "label": "Показывать полный diff в карточках изменений", + "description": "Включает полную unified diff для каждого файла в карточках изменений по шагам. При выключенном показе карточки отображают только список файлов с количеством добавленных/удалённых строк." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index aff7402428..e39fa9003a 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Her dosya yazımından sonra kontrol noktası", "description": "Ajanın her başarılı dosya yazımından sonra bir kontrol noktası görüntüsü kaydeder" + }, + "changeCardDetail": { + "label": "Değişiklik kartlarında tam diff'i göster", + "description": "Adım başına değişiklik kartlarında her dosya için tam birleşik diff'i satır içi olarak içerir. Kapalıyken kartlar yalnızca eklenen/çıkarılan satır sayıları dosya listesini gösterir." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e944a56780..3be7cff5be 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "Điểm kiểm tra sau mỗi lần ghi file", "description": "Ghi lại ảnh chụp nhanh điểm kiểm tra sau mỗi lần ghi file thành công của agent" + }, + "changeCardDetail": { + "label": "Hiển thị diff đầy đủ trong thẻ thay đổi", + "description": "Bao gồm diff thống nhất đầy đủ nội tuyến cho từng tệp trong thẻ thay đổi theo bước. Khi tắt, thẻ chỉ hiển thị danh sách tệp với số dòng thêm/xóa." } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 05d04463e1..8102ba76d8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -705,6 +705,10 @@ "perWrite": { "label": "每次文件写入后创建存档点", "description": "智能体每次成功写入文件后都会记录一个存档点快照" + }, + "changeCardDetail": { + "label": "在变更卡片中显示完整 diff", + "description": "在逐步变更卡片中为每个文件内嵌完整 unified diff。关闭时,卡片仅显示文件列表与新增/删除行数。" } }, "notifications": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index a2ef47b841..69886d6479 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -732,6 +732,10 @@ "perWrite": { "label": "每次檔案寫入後建立檢查點", "description": "代理每次成功寫入檔案後都會記錄一個檢查點快照" + }, + "changeCardDetail": { + "label": "在變更卡片中顯示完整 diff", + "description": "在逐步變更卡片中為每個檔案內嵌完整 unified diff。關閉時,卡片僅顯示檔案清單與新增/刪除行數。" } }, "notifications": { From d0c60bfbbb52c0981e1143ce0812e312fc71538b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 01:26:35 +0800 Subject: [PATCH 26/46] feat(checkpoints): per-file and per-step rollback service (B3c, #1375) --- .../checkpoints/__tests__/rollback.spec.ts | 198 ++++++++++++++++++ src/core/checkpoints/rollback.ts | 119 +++++++++++ .../checkpoints/ShadowCheckpointService.ts | 67 ++++++ .../__tests__/ShadowCheckpointService.spec.ts | 112 ++++++++++ 4 files changed, 496 insertions(+) create mode 100644 src/core/checkpoints/__tests__/rollback.spec.ts create mode 100644 src/core/checkpoints/rollback.ts diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts new file mode 100644 index 0000000000..36a7031497 --- /dev/null +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -0,0 +1,198 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import type { Task } from "../../task/Task" +import { getCheckpointService } from "../index" +import { appendChange } from "../changeJournal" +import { rollbackFile, rollbackStep } from "../rollback" + +vi.mock("../index", () => ({ + getCheckpointService: vi.fn(), + checkpointSave: vi.fn(), + checkpointRestore: vi.fn(), + checkpointDiff: vi.fn(), +})) + +const mockedGetCheckpointService = getCheckpointService as unknown as ReturnType + +function makeTask(): Task { + return { + taskId: "task-rollback", + providerRef: { + deref: vi.fn().mockReturnValue({ context: { globalStorageUri: { fsPath: globalStorageDir } } }), + }, + } as unknown as Task +} + +let globalStorageDir: string + +beforeEach(async () => { + globalStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b3a-rollback-")) + mockedGetCheckpointService.mockReset() +}) + +afterEach(async () => { + await fs.rm(globalStorageDir, { recursive: true, force: true }) +}) + +describe("rollbackFile (B3a)", () => { + it("restores the file through the task's checkpoint service", async () => { + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: true }) + expect(restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") + }) + + it("fails cleanly when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Checkpoints are not enabled for this task", + }) + }) + + it("reports the service error without throwing", async () => { + mockedGetCheckpointService.mockResolvedValue({ + restoreFile: vi.fn().mockRejectedValue(new Error("pathspec did not match")), + }) + + const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("pathspec did not match") + }) + + it("stringifies non-Error rejections into the outcome", async () => { + mockedGetCheckpointService.mockResolvedValue({ + restoreFile: vi.fn().mockRejectedValue("raw failure"), + }) + + const outcome = await rollbackFile(makeTask(), "sha-bad", "src/a.ts") + + expect(outcome).toEqual({ filePath: "src/a.ts", success: false, error: "raw failure" }) + }) +}) + +describe("rollbackStep (B3a)", () => { + it("restores every step file from the step's checkpoint via the journal", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-step", + }) + await appendChange(globalStorageDir, "task-rollback", { + path: "src/b.ts", + operation: "update", + checkpointId: "sha-step", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-step") + + expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ]) + expect(restoreFile).toHaveBeenCalledTimes(2) + expect(restoreFile).toHaveBeenNthCalledWith(1, "sha-step", "src/a.ts") + expect(restoreFile).toHaveBeenNthCalledWith(2, "sha-step", "src/b.ts") + }) + + it("rejects a file that is not part of the given step checkpoint", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-step", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/other.ts"], "sha-step") + + expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) + expect(outcome.files[1].success).toBe(false) + expect(outcome.files[1].error).toBe("File is not part of this step's checkpoint") + expect(restoreFile).toHaveBeenCalledTimes(1) + }) + + it("falls back to the latest journal entry per file without a step checkpoint id", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-1", + }) + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "update", + checkpointId: "sha-2", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"]) + + expect(outcome.checkpointId).toBe("sha-2") + expect(restoreFile).toHaveBeenCalledWith("sha-2", "src/a.ts") + }) + + it("fails listed files without journal entries and keeps the checkpoint when resolvable", async () => { + await appendChange(globalStorageDir, "task-rollback", { + path: "src/a.ts", + operation: "create", + checkpointId: "sha-1", + }) + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/missing.ts"]) + + expect(outcome.checkpointId).toBe("sha-1") + expect(outcome.files[0]).toEqual({ filePath: "src/a.ts", success: true }) + expect(outcome.files[1].success).toBe(false) + expect(outcome.files[1].error).toBe("No change journal entry for this file") + }) + + it("treats a missing global storage directory as an empty journal", async () => { + // No context on the provider double → no journal location to read. + const task = { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(undefined) }, + } as unknown as Task + + const restoreFile = vi.fn().mockResolvedValue(undefined) + mockedGetCheckpointService.mockResolvedValue({ restoreFile }) + + const outcome = await rollbackStep(task, ["src/a.ts"], "sha-step") + + expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toBe("File is not part of this step's checkpoint") + expect(restoreFile).not.toHaveBeenCalled() + }) + it("fails every file when checkpoints are not enabled", async () => { + mockedGetCheckpointService.mockResolvedValue(undefined) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts"], "sha-step") + + expect(outcome.checkpointId).toBe("sha-step") + expect(outcome.files).toEqual([ + { filePath: "src/a.ts", success: false, error: "Checkpoints are not enabled for this task" }, + ]) + }) +}) diff --git a/src/core/checkpoints/rollback.ts b/src/core/checkpoints/rollback.ts new file mode 100644 index 0000000000..e2be09b621 --- /dev/null +++ b/src/core/checkpoints/rollback.ts @@ -0,0 +1,119 @@ +/** + * Per-file / per-step checkpoint rollback (B3a). + * + * Restores reuse the existing shadow-git service (`getCheckpointService` → + * `RepoPerTaskCheckpointService.restoreFile`, the same instance whose + * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. + * + * - `rollbackFile` restores one file to the content it had at an explicit + * checkpoint commit (the "restore to any checkpoint" primitive). + * - `rollbackStep` restores every file a step touched to that step's + * checkpoint. The step's checkpoint is resolved from the B2 change + * journal (`changes.jsonl`), whose entries key each written file by the + * checkpoint commit it produced. When the caller already knows the step's + * checkpoint id (the change-card payload carries it), pass it so the + * journal is filtered to exactly that step's writes. + */ +import type { Task } from "../task/Task" + +import { getCheckpointService } from "./index" +import { loadChanges, type ChangeJournalEntry } from "./changeJournal" + +export interface RollbackFileOutcome { + filePath: string + success: boolean + error?: string +} + +export interface RollbackStepOutcome { + /** The step checkpoint the files were restored from, when resolvable. */ + checkpointId?: string + files: RollbackFileOutcome[] +} + +const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task" + +/** + * Restore a single file to its state at `checkpointId`. + * + * Only the named file's working-tree content is replaced; the shadow repo's + * HEAD and the checkpoint list are untouched (unlike a full + * `restoreCheckpoint`). + */ +export async function rollbackFile(task: Task, checkpointId: string, filePath: string): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { filePath, success: false, error: NOT_ENABLED_ERROR } + } + + try { + await service.restoreFile(checkpointId, filePath) + return { filePath, success: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[rollbackFile] failed to restore ${filePath} from checkpoint ${checkpointId}: ${message}`) + return { filePath, success: false, error: message } + } +} + +/** + * Restore every file of a step to the step's checkpoint. + * + * `stepFiles` comes from the B2 journal entries for the step's checkpoint id + * (which the change-card payload also carries). Each file is resolved to its + * step checkpoint through the journal: + * - with `stepCheckpointId`, only entries for that checkpoint are considered + * (exactly the step's writes), and every listed file is restored from it; + * - without it, the latest journal entry per file is used as a fallback. + */ +export async function rollbackStep( + task: Task, + stepFiles: string[], + stepCheckpointId?: string, +): Promise { + const service = await getCheckpointService(task) + + if (!service) { + return { + checkpointId: stepCheckpointId, + files: stepFiles.map((filePath) => ({ filePath, success: false, error: NOT_ENABLED_ERROR })), + } + } + + // loadChanges never throws: an absent or torn journal resolves to its + // readable prefix (or []), so no error handling is needed here. + const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath + const entries = globalStorageDir ? await loadChanges(globalStorageDir, task.taskId) : [] + + // Latest journal entry per file (journal lines preserve write order). + const latestByPath = new Map() + for (const entry of entries) { + latestByPath.set(entry.path, entry) + } + + const checkpointId = + stepCheckpointId ?? stepFiles.map((filePath) => latestByPath.get(filePath)?.checkpointId).find(Boolean) + + const files: RollbackFileOutcome[] = [] + for (const filePath of stepFiles) { + const entry = stepCheckpointId + ? entries.find((e) => e.path === filePath && e.checkpointId === stepCheckpointId) + : latestByPath.get(filePath) + + if (!entry) { + files.push({ + filePath, + success: false, + error: stepCheckpointId + ? "File is not part of this step's checkpoint" + : "No change journal entry for this file", + }) + continue + } + + files.push(await rollbackFile(task, entry.checkpointId, filePath)) + } + + return { checkpointId, files } +} diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 3e3d3d0653..203d1c4727 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -407,6 +407,73 @@ export abstract class ShadowCheckpointService extends EventEmitter { } } + /** + * Restore a single file to its state at `commitHash` without moving the + * branch or truncating the checkpoint list (unlike + * {@link restoreCheckpoint}). + * + * If the file did not exist at `commitHash`, it is removed from the + * working tree instead — rolling a file back to before it was created. + */ + public async restoreFile(commitHash: string, filePath: string): Promise { + try { + this.log(`[${this.constructor.name}#restoreFile] restoring ${filePath} from ${commitHash}`) + + if (!this.git) { + throw new Error("Shadow git repo not initialized") + } + + // Git pathspecs are always POSIX: normalize a native (Windows + // backslash) path before the git calls. Without this, `cat-file -e` on + // a backslashed path never matches, the file is treated as absent at + // the checkpoint, and the delete branch below would remove a file the + // checkpoint actually contains. The local fs.rm join keeps the native + // form, since the OS treats both separators interchangeably there. + const gitPath = filePath.toPosix() + + // Constrain the path to the workspace before either branch: `..` + // segments would otherwise normalize (path.join / git pathspec) to a + // location outside `this.workspaceDir`, and the delete branch could + // remove an unrelated file. `path.resolve` normalizes the segments; + // the trailing-separator prefix check is the containment guard. + const resolvedTarget = path.resolve(this.workspaceDir, filePath) + const workspaceRoot = this.workspaceDir.endsWith(path.sep) + ? this.workspaceDir + : this.workspaceDir + path.sep + if (resolvedTarget !== this.workspaceDir && !resolvedTarget.startsWith(workspaceRoot)) { + throw new Error(`restoreFile target is outside the workspace: ${filePath}`) + } + + const start = Date.now() + const existed = await this.fileExistsInCommit(commitHash, gitPath) + + if (existed) { + await this.git.checkout([commitHash, "--", gitPath]) + } else { + await fs.rm(path.join(this.workspaceDir, filePath), { force: true }) + } + + const duration = Date.now() - start + this.emit("restore", { type: "restore", commitHash, duration }) + this.log(`[${this.constructor.name}#restoreFile] restored ${filePath} in ${duration}ms`) + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)) + this.log(`[${this.constructor.name}#restoreFile] failed to restore file: ${error.message}`) + this.emit("error", { type: "error", error }) + throw error + } + } + + /** Whether `filePath` exists in the tree of `commitHash`. */ + private async fileExistsInCommit(commitHash: string, filePath: string): Promise { + try { + await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`]) + return true + } catch { + return false + } + } + public async getDiff({ from, to }: { from?: string; to?: string }): Promise { if (!this.git) { throw new Error("Shadow git repo not initialized") diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 1710cc97e8..0b9a48cb75 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -165,6 +165,118 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( }) }) + describe(`${klass.name}#restoreFile`, () => { + it("restores a modified file to a previous checkpoint without touching other files", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "New file content") + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + + // Drift both files, then roll back only test.txt to commit 1. + await fs.writeFile(testFile, "Changed after checkpoint") + await fs.writeFile(newFile, "Also changed") + + await service.restoreFile(commit1!.commit, "test.txt") + + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + // new.txt is not part of the restore and keeps its drifted content. + expect(await fs.readFile(newFile, "utf-8")).toBe("Also changed") + }) + + it("deletes a file that did not exist at the checkpoint", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "Created after the checkpoint") + + await service.restoreFile(commit1!.commit, "new.txt") + + expect(await fileExistsAtPath(newFile)).toBe(false) + // The untouched file keeps its checkpoint-1 content. + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("accepts a native backslashed path on Windows (git pathspecs are POSIX)", async () => { + if (process.platform !== "win32") { + // Off-Windows the journal paths are already POSIX, so there is + // nothing to normalize; the toPosix() call is still exercised + // as a no-op by every other restoreFile test. + return + } + + // A nested file, so the relative path actually contains a + // separator that Windows writes as a backslash. + const subFile = path.join(path.dirname(testFile), "subdir", "inner.txt") + await fs.mkdir(path.dirname(subFile), { recursive: true }) + await fs.writeFile(subFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(subFile, "Drifted") + + // The change journal and the webview hand backslashed paths to the + // rollback service on Windows; restoreFile must normalize them for + // the git calls. Without the normalization, `cat-file -e` would + // never match and the delete branch would remove a file the + // checkpoint actually contains. + await service.restoreFile(commit1!.commit, "subdir" + path.sep + "inner.txt") + + expect(await fs.readFile(subFile, "utf-8")).toBe("Ahoy, world!") + }) + + it("rejects a restore target that escapes the workspace", async () => { + // `..` segments must be normalized and contained, so neither the + // checkout nor the delete branch can touch a file outside the + // workspace (CWE-22). + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await expect(service.restoreFile(commit1!.commit, path.join("..", "escape.txt"))).rejects.toThrow( + /outside the workspace/, + ) + }) + + it("emits a restore event when a file is restored", async () => { + const restoreListener = vi.fn() + service.on("restore", restoreListener) + + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(testFile, "Drifted") + await service.restoreFile(commit1!.commit, "test.txt") + + expect(restoreListener).toHaveBeenCalledWith( + expect.objectContaining({ type: "restore", commitHash: commit1!.commit }), + ) + }) + + it("throws and emits an error event when the shadow repo is not initialized", async () => { + // A service that never ran initShadowGit has no git handle; restoreFile + // must fail cleanly and surface the error event. + const raw = await klass.create({ + taskId, + shadowDir: path.join(tmpDir, `noinit-${Date.now()}`), + workspaceDir: path.join(tmpDir, `ws-noinit-${Date.now()}`), + log: () => {}, + }) + + const errorListener = vi.fn() + raw.on("error", errorListener) + + await expect(raw.restoreFile("sha-x", "test.txt")).rejects.toThrow("Shadow git repo not initialized") + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })) + }) + }) + describe(`${klass.name}#saveCheckpoint`, () => { it("creates a checkpoint if there are pending changes", async () => { await fs.writeFile(testFile, "Ahoy, world!") From 4c16c00178feac4a1dfd0a80a7b51ff05744a109 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 01:52:48 +0800 Subject: [PATCH 27/46] feat(webview): change cards UI and rollback buttons (B3b, #1375) --- packages/types/src/vscode-extension-host.ts | 53 +++ .../webviewMessageHandler.rollback.spec.ts | 206 ++++++++++++ src/core/webview/webviewMessageHandler.ts | 81 +++++ webview-ui/src/components/chat/ChangeCard.tsx | 309 +++++++++++++++++ webview-ui/src/components/chat/ChatRow.tsx | 3 + .../chat/__tests__/ChangeCard.spec.tsx | 312 ++++++++++++++++++ .../settings/CheckpointSettings.tsx | 25 +- .../src/components/settings/SettingsView.tsx | 4 + .../__tests__/CheckpointSettings.spec.tsx | 51 +++ .../settings/__tests__/SettingsView.spec.tsx | 41 +++ webview-ui/src/i18n/locales/ca/chat.json | 12 + webview-ui/src/i18n/locales/de/chat.json | 12 + webview-ui/src/i18n/locales/en/chat.json | 12 + webview-ui/src/i18n/locales/es/chat.json | 12 + webview-ui/src/i18n/locales/fr/chat.json | 12 + webview-ui/src/i18n/locales/hi/chat.json | 12 + webview-ui/src/i18n/locales/id/chat.json | 12 + webview-ui/src/i18n/locales/it/chat.json | 12 + webview-ui/src/i18n/locales/ja/chat.json | 12 + webview-ui/src/i18n/locales/ko/chat.json | 12 + webview-ui/src/i18n/locales/nl/chat.json | 12 + webview-ui/src/i18n/locales/pl/chat.json | 12 + webview-ui/src/i18n/locales/pt-BR/chat.json | 12 + webview-ui/src/i18n/locales/ru/chat.json | 12 + webview-ui/src/i18n/locales/tr/chat.json | 12 + webview-ui/src/i18n/locales/vi/chat.json | 12 + webview-ui/src/i18n/locales/zh-CN/chat.json | 12 + webview-ui/src/i18n/locales/zh-TW/chat.json | 12 + 28 files changed, 1300 insertions(+), 1 deletion(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts create mode 100644 webview-ui/src/components/chat/ChangeCard.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 9b43a75a1d..b1bfe084f2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -108,6 +108,7 @@ export interface ExtensionMessage { | "fileContent" | "rooHistoryImportProgress" | "themeFixtureProbeRequest" + | "checkpointRollbackResult" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -254,6 +255,28 @@ export interface ExtensionMessage { copyProgressItemName?: string // folderSelected path?: string + /** For checkpointRollbackResult: outcome of a change-card rollback request (B3b). */ + checkpointRollbackResult?: CheckpointRollbackResult +} + +/** + * CheckpointRollbackResult + * + * Outcome of a change-card rollback request (B3b), posted back to the webview + * that sent `checkpointRollbackFile` / `checkpointRollbackStep`. `cardTs` + * echoes the change-card message timestamp so the requesting card can + * correlate the result: per-file results carry `filePath`, per-step results + * carry the per-file outcomes in `files`. + */ +export interface CheckpointRollbackResult { + /** The `ts` of the change_card message the result belongs to. */ + cardTs: number + /** Per-file scope: the file that was restored. */ + filePath?: string + success: boolean + error?: string + /** Per-step scope: the per-file outcomes. */ + files?: { filePath: string; success: boolean; error?: string }[] } export interface OpenAiCodexRateLimitsMessage { @@ -546,6 +569,8 @@ export interface WebviewMessage { | "openCustomModesSettings" | "checkpointDiff" | "checkpointRestore" + | "checkpointRollbackFile" + | "checkpointRollbackStep" | "completionCheckpointDiff" | "completionCheckpointRestore" | "deleteMcpServer" @@ -788,6 +813,32 @@ export const checkoutRestorePayloadSchema = z.object({ export type CheckpointRestorePayload = z.infer +/** + * Payload of the `checkpointRollbackFile` webview message (B3b): restore one + * change-card file to the checkpoint commit the card was keyed by. + */ +export const checkpointRollbackFilePayloadSchema = z.object({ + /** The `ts` of the change_card message the request comes from (echoed on the result). */ + cardTs: z.number(), + checkpointId: z.string(), + filePath: z.string(), +}) + +export type CheckpointRollbackFilePayload = z.infer + +/** + * Payload of the `checkpointRollbackStep` webview message (B3b): restore + * every file of a change-card step to the step's checkpoint. + */ +export const checkpointRollbackStepPayloadSchema = z.object({ + cardTs: z.number(), + /** The step's checkpoint commit (the card's first checkpointId); optional. */ + checkpointId: z.string().optional(), + filePaths: z.array(z.string()).min(1), +}) + +export type CheckpointRollbackStepPayload = z.infer + export interface IndexingStatusPayload { state: "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" message: string @@ -801,6 +852,8 @@ export interface IndexClearedPayload { export type WebViewMessagePayload = | CheckpointDiffPayload | CheckpointRestorePayload + | CheckpointRollbackFilePayload + | CheckpointRollbackStepPayload | IndexingStatusPayload | IndexClearedPayload | UpdateTodoListPayload diff --git a/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts new file mode 100644 index 0000000000..74a341c26d --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts @@ -0,0 +1,206 @@ +// npx vitest run src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts +import { describe, expect, it, vi, beforeEach } from "vitest" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { webviewMessageHandler } from "../webviewMessageHandler" +import { rollbackFile, rollbackStep } from "../../checkpoints/rollback" +import type { Task } from "../../task/Task" +import type { ClineProvider } from "../ClineProvider" + +// The rollback cases only call these two provider methods, so the provider +// double below is cast once at this boundary; the spy is shared so results +// can be asserted after the handler runs. +vi.mock("../../checkpoints/rollback", () => ({ + rollbackFile: vi.fn(), + rollbackStep: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + }, +})) + +// Structural mock: the handler only needs the task identity for these cases. +const mockTask = {} as Task +const postMessageToWebview = vi.fn(async (_message: ExtensionMessage) => undefined) + +function makeProvider(task: Task | undefined): ClineProvider { + const provider = { + getCurrentTask: () => task, + postMessageToWebview, + } + // Cast at the spec boundary: the rollback cases only read getCurrentTask() + // and observe postMessageToWebview calls on the structural double. + return provider as unknown as ClineProvider +} + +const provider = makeProvider(mockTask) + +describe("webviewMessageHandler - change card rollback", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("checkpointRollbackFile", () => { + it("restores the file and posts the success outcome back to the webview", async () => { + vi.mocked(rollbackFile).mockResolvedValueOnce({ filePath: "src/a.ts", success: true }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + expect(rollbackFile).toHaveBeenCalledWith(mockTask, "abc123", "src/a.ts") + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, filePath: "src/a.ts", success: true }, + }) + }) + + it("posts the error outcome when the restore fails", async () => { + vi.mocked(rollbackFile).mockResolvedValueOnce({ + filePath: "src/a.ts", + success: false, + error: "checkpoint not found", + }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "checkpoint not found", + }, + }) + }) + + it("posts a correlated failure result when there is no current task", async () => { + const emptyProvider = makeProvider(undefined) + + await webviewMessageHandler(emptyProvider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + expect(rollbackFile).not.toHaveBeenCalled() + // The requesting card must clear its pending state, so the handler + // posts a correlated failure instead of nothing. + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "No active task to roll back from.", + }, + }) + }) + }) + + describe("checkpointRollbackStep", () => { + it("restores every step file and posts the aggregated outcome", async () => { + vi.mocked(rollbackStep).mockResolvedValueOnce({ + checkpointId: "abc123", + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ], + }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, checkpointId: "abc123", filePaths: ["src/a.ts", "src/b.ts"] }, + }) + + expect(rollbackStep).toHaveBeenCalledWith(mockTask, ["src/a.ts", "src/b.ts"], "abc123") + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: true, + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ], + }, + }) + }) + + it("reports success false with the first failing file's error when a step file fails", async () => { + vi.mocked(rollbackStep).mockResolvedValueOnce({ + checkpointId: "abc123", + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: false, error: "boom" }, + ], + }) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: ["src/a.ts", "src/b.ts"] }, + }) + + // Without an explicit step checkpoint id the journal lookup is used. + expect(rollbackStep).toHaveBeenCalledWith(mockTask, ["src/a.ts", "src/b.ts"], undefined) + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "boom", + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: false, error: "boom" }, + ], + }, + }) + }) + + it("posts a correlated failure result when there is no current task", async () => { + const emptyProvider = makeProvider(undefined) + + await webviewMessageHandler(emptyProvider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: ["src/a.ts"] }, + }) + + expect(rollbackStep).not.toHaveBeenCalled() + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "No active task to roll back from.", + }, + }) + }) + + it("ignores payloads that do not match the schema", async () => { + await webviewMessageHandler(provider, { + // Malformed on purpose (only the webview produces this message): the cast + // lets the spec reach the handler's safeParse rejection without `any`. + type: "checkpointRollbackFile", + payload: { cardTs: 1000 } as unknown as WebviewMessage["payload"], + }) + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: [] }, + }) + + expect(rollbackFile).not.toHaveBeenCalled() + expect(rollbackStep).not.toHaveBeenCalled() + expect(postMessageToWebview).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..876fec270e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -19,6 +19,8 @@ import { TelemetryEventName, RooCodeSettings, ExperimentId, + checkpointRollbackFilePayloadSchema, + checkpointRollbackStepPayloadSchema, checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, getCompletionCheckpoint, @@ -1598,6 +1600,85 @@ export const webviewMessageHandler = async ( break } + case "checkpointRollbackFile": { + // B3b: restore one change-card file to its step checkpoint and report + // the outcome back to the requesting card (correlated by cardTs). + const result = checkpointRollbackFilePayloadSchema.safeParse(message.payload) + + if (result.success) { + const task = provider.getCurrentTask() + + if (task) { + // Lazy import: the rollback module pulls the checkpoint service and the + // editor integrations (DiffViewProvider) into the import graph. Loading it + // only when a rollback is requested keeps specs that mock `vscode` minimally + // from executing editor module-scope code at import time. + const { rollbackFile } = await import("../checkpoints/rollback") + const outcome = await rollbackFile(task, result.data.checkpointId, result.data.filePath) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: outcome.filePath, + success: outcome.success, + ...(outcome.error ? { error: outcome.error } : {}), + }, + }) + } else { + // No active task: the rollback cannot run. Post the correlated + // failure so the requesting card can clear its pending state + // instead of waiting on a result that will never arrive. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: result.data.filePath, + success: false, + error: "No active task to roll back from.", + }, + }) + } + } + + break + } + case "checkpointRollbackStep": { + // B3b: restore every file of a change-card step to the step checkpoint. + const result = checkpointRollbackStepPayloadSchema.safeParse(message.payload) + + if (result.success) { + const task = provider.getCurrentTask() + + if (task) { + // Lazy import (see the checkpointRollbackFile case above). + const { rollbackStep } = await import("../checkpoints/rollback") + const outcome = await rollbackStep(task, result.data.filePaths, result.data.checkpointId) + const firstFailure = outcome.files.find((file) => !file.success) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: outcome.files.every((file) => file.success), + ...(firstFailure ? { error: firstFailure.error } : {}), + files: outcome.files, + }, + }) + } else { + // No active task: post the correlated failure so the requesting + // card can clear its pending state. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: false, + error: "No active task to roll back from.", + }, + }) + } + } + + break + } case "completionCheckpointDiff": { const currentCline = provider.getCurrentTask() const checkpoint = currentCline ? resolveCompletionCheckpoint(currentCline) : undefined diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx new file mode 100644 index 0000000000..aa863e7a33 --- /dev/null +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -0,0 +1,309 @@ +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" +import { Check, FileDiff, RotateCcw, X } from "lucide-react" +import { safeJsonParse } from "@roo/core" + +import { changeCardSchema, type ClineMessage, type ExtensionMessage } from "@roo-code/types" + +import { Button, StandardTooltip } from "@/components/ui" +import { vscode } from "@src/utils/vscode" +import { formatPathTooltip } from "@src/utils/formatPathTooltip" + +import CodeAccordion from "../common/CodeAccordion" + +type RollbackStatus = "idle" | "confirming" | "pending" | "success" | "error" + +type RollbackState = { + status: RollbackStatus + error?: string +} + +const IDLE: RollbackState = { status: "idle" } + +const successState: RollbackState = { status: "success" } + +/** + * Per-step change card (B3a payload, B3b UI): header with the file count, a + * per-file list with +/− diff badges, and per-file / per-step rollback + * controls wired to the extension host through the + * `checkpointRollbackFile` / `checkpointRollbackStep` messages. Diffs come from + * the payload's per-file `diff` field: `full` cards expand by default, + * `summary` cards expand lazily on toggle, compact cards carry no diff. + */ +export const ChangeCard = ({ message }: { message: ClineMessage }) => { + const { t } = useTranslation() + + const card = useMemo(() => { + // Validate the shape, not just the JSON-ness: this text is persisted + // task history, so a truncated or pre-series record must not throw + // during render. Records that fail to parse (safeJsonParse returns + // `undefined` without a default) or fail shape validation fall through + // to the null path (an inert card row). + const parsed = safeJsonParse(message.text) + if (parsed === undefined) { + return null + } + const validated = changeCardSchema.safeParse(parsed) + return validated.success ? validated.data : null + }, [message.text]) + + // Files whose diff is currently expanded. "full" cards expand inline by + // default; "summary" cards keep the diff collapsed until toggled. + const [expandedFiles, setExpandedFiles] = useState>(() => { + if (!card || card.detail !== "full") { + return new Set() + } + return new Set(card.files.filter((file) => file.diff != null).map((file) => file.path)) + }) + + const [fileRollbacks, setFileRollbacks] = useState>({}) + const [stepRollback, setStepRollback] = useState(IDLE) + + const checkpointId = card?.checkpointIds[0] + + // Correlate extension rollback results with this card by message ts. + useEffect(() => { + const handler = (event: MessageEvent) => { + const data = event.data as ExtensionMessage | undefined + const result = data?.checkpointRollbackResult + if (data?.type !== "checkpointRollbackResult" || !result) { + return + } + if (result.cardTs !== message.ts) { + return + } + const filePath = result.filePath + if (filePath !== undefined) { + setFileRollbacks((prev) => ({ + ...prev, + [filePath]: result.success ? successState : { status: "error", error: result.error }, + })) + } + if (result.files) { + const fileUpdates: Record = {} + for (const file of result.files) { + fileUpdates[file.filePath] = file.success ? successState : { status: "error", error: file.error } + } + setFileRollbacks((prev) => ({ ...prev, ...fileUpdates })) + const firstFailure = result.files.find((file) => !file.success) + setStepRollback( + result.success ? successState : { status: "error", error: firstFailure?.error ?? result.error }, + ) + } + } + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [message.ts]) + + if (!card || !checkpointId) { + return null + } + + const toggleFile = (path: string) => { + setExpandedFiles((prev) => { + const next = new Set(prev) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + } + + const requestFileRollback = (path: string) => { + vscode.postMessage({ + type: "checkpointRollbackFile", + payload: { cardTs: message.ts, checkpointId, filePath: path }, + }) + setFileRollbacks((prev) => ({ ...prev, [path]: { status: "pending" } })) + } + + const requestStepRollback = () => { + vscode.postMessage({ + type: "checkpointRollbackStep", + payload: { cardTs: message.ts, checkpointId, filePaths: card.files.map((file) => file.path) }, + }) + setStepRollback({ status: "pending" }) + } + + const diffBadges = (additions: number, deletions: number) => + additions > 0 || deletions > 0 ? ( + + +{additions} + -{deletions} + + ) : null + + const fileRollbackControls = (path: string, index: number) => { + const state = fileRollbacks[path] ?? IDLE + const confirmTestId = `change-card-file-confirm-${index}` + const cancelTestId = `change-card-file-cancel-${index}` + + switch (state.status) { + case "confirming": + return ( + + + + + ) + case "pending": + return ( + + + + ) + case "success": + return ( + + + {t("chat:changeCard.rolledBack")} + + ) + case "error": + return ( + + + + {t("chat:changeCard.rollbackFailed")} + + + ) + default: + return ( + + + + ) + } + } + + const stepRollbackControls = () => { + switch (stepRollback.status) { + case "confirming": + return ( + + + {t("chat:changeCard.rollbackWarning")} + + + + + ) + case "pending": + return ( + + + {t("chat:changeCard.rollingBack")} + + ) + case "success": + return ( + + + {t("chat:changeCard.stepRolledBack")} + + ) + case "error": + return ( + + + + {t("chat:changeCard.rollbackFailed")} + + + ) + default: + return ( + + ) + } + } + + return ( +
+
+ + + {t("chat:changeCard.header", { count: card.totalFiles })} + + + {stepRollbackControls()} +
+
+ {card.files.map((file, index) => ( +
+
+ {file.diff != null ? ( + toggleFile(file.path)} + diffStats={{ added: file.additions, removed: file.deletions }} + /> + ) : ( +
+ + {formatPathTooltip(file.path)} + + + {diffBadges(file.additions, file.deletions)} +
+ )} +
+
{fileRollbackControls(file.path, index)}
+
+ ))} +
+
+ ) +} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 952322084f..6d13bae0a0 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -39,6 +39,7 @@ import McpResourceRow from "../mcp/McpResourceRow" import { Mention } from "./Mention" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" +import { ChangeCard } from "./ChangeCard" import { FollowUpSuggest } from "./FollowUpSuggest" import { BatchFilePermission } from "./BatchFilePermission" import { BatchDiffApproval } from "./BatchDiffApproval" @@ -1374,6 +1375,8 @@ export const ChatRowContent = ({ onJumpToPreviousCheckpoint={onJumpToPreviousCheckpoint} /> ) + case "change_card": + return case "condense_context": // In-progress state if (message.partial) { diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx new file mode 100644 index 0000000000..78ba90a17c --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -0,0 +1,312 @@ +// npx vitest run src/components/chat/__tests__/ChangeCard.spec.tsx + +import React from "react" +import { fireEvent, renderWithExtensionState, screen } from "@/utils/test-utils" +import type { ChangeCardData, ClineMessage } from "@roo-code/types" + +const mockPostMessage = vi.fn() + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + +// Mock i18n (same pattern as the other ChatRow specs) +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => { + const map: Record = { + "chat:changeCard.header": `${options?.count ?? 0} file(s) changed this step`, + "chat:changeCard.rollbackFile": "Rollback this file", + "chat:changeCard.rollbackStep": "Rollback step", + "chat:changeCard.rollbackWarning": "Restores the previous content of this step's files.", + "chat:changeCard.confirm": "Confirm", + "chat:changeCard.cancel": "Cancel", + "chat:changeCard.rollingBack": "Rolling back...", + "chat:changeCard.rolledBack": "Rolled back", + "chat:changeCard.stepRolledBack": "Step rolled back", + "chat:changeCard.rollbackFailed": "Rollback failed", + } + return map[key] || key + }, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock DiffView so the diff text is directly assertable (the real one runs a +// syntax highlighter, which is irrelevant to the lazy-expansion behavior). +vi.mock("@src/components/common/DiffView", () => ({ + default: ({ source }: { source: string }) =>
{source}
, +})) + +import { ChangeCard } from "../ChangeCard" +import { ChatRowContent } from "../ChatRow" + +function makeCardMessage(overrides: Partial = {}, ts = 1000): ClineMessage { + const card: ChangeCardData = { + checkpointIds: ["abc123"], + files: [ + { path: "src/a.ts", additions: 12, deletions: 3 }, + { path: "src/b.ts", additions: 1, deletions: 1 }, + ], + totalFiles: 2, + detail: "summary", + ...overrides, + } + return { + type: "say", + say: "change_card", + ts, + partial: false, + text: JSON.stringify(card), + } +} + +const DIFF_A = "@@ -1,1 +1,2 @@\n-old\n+new-a\n+extra\n" +const DIFF_B = "@@ -1,1 +1,1 @@\n-old\n+new-b\n" + +function fireRollbackResult(data: Record) { + fireEvent(window, new MessageEvent("message", { data })) +} + +describe("ChangeCard", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the header count and per-file list from a multi-file payload", () => { + renderWithExtensionState() + + expect(screen.getByTestId("change-card-header")).toHaveTextContent("2 file(s) changed this step") + expect(screen.getByText((text) => text.includes("src/a.ts"))).toBeInTheDocument() + expect(screen.getByText((text) => text.includes("src/b.ts"))).toBeInTheDocument() + expect(screen.getByText("+12")).toBeInTheDocument() + expect(screen.getByText("-3")).toBeInTheDocument() + expect(screen.getByText("+1")).toBeInTheDocument() + expect(screen.getByText("-1")).toBeInTheDocument() + }) + + it("keeps the diff hidden in summary cards until the file row is expanded", () => { + renderWithExtensionState( + , + ) + + // Collapsed by default: the diff text is not rendered. + expect(screen.queryByTestId("diff-view")).toBeNull() + expect(screen.queryByText((content) => content.includes("+new-a"))).toBeNull() + + // Expand the file row. + fireEvent.click(screen.getByText((text) => text.includes("src/a.ts"))) + + // The diff text comes from the payload and is rendered lazily on expand. + expect(screen.getByTestId("diff-view").textContent).toContain(DIFF_A.trim()) + + // Collapse again. + fireEvent.click(screen.getByText((text) => text.includes("src/a.ts"))) + expect(screen.queryByTestId("diff-view")).toBeNull() + }) + + it("renders the diff inline by default in full cards", () => { + renderWithExtensionState( + , + ) + + const [diffA, diffB] = screen.getAllByTestId("diff-view") + expect(diffA.textContent).toContain(DIFF_A.trim()) + expect(diffB.textContent).toContain(DIFF_B.trim()) + }) + + it("renders compact rows without a diff section when the payload carries no diffs", () => { + // Auto-approved steps are always emitted as summary cards without any + // per-file diff field; the card then renders file rows with stats only. + renderWithExtensionState() + + expect(screen.getByTestId("change-card-header")).toBeInTheDocument() + expect(screen.queryByTestId("diff-view")).toBeNull() + expect(screen.getByText((text) => text.includes("src/a.ts"))).toBeInTheDocument() + expect(screen.getByText((text) => text.includes("src/b.ts"))).toBeInTheDocument() + }) + + it("renders nothing for an unparseable card payload", () => { + const { container } = renderWithExtensionState( + , + ) + + expect(container.innerHTML).toBe("") + }) + + it("rolls back one file through the checkpointRollbackFile message and shows pending + success", async () => { + renderWithExtensionState() + + // Open the confirm step for the first file. + fireEvent.click(screen.getByTestId("change-card-file-rollback-0")) + expect(screen.getByTestId("change-card-file-confirm-0")).toBeInTheDocument() + + // Confirm sends the webview->extension message and goes pending. + fireEvent.click(screen.getByText("Confirm")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + expect(screen.getByTestId("change-card-file-pending-0")).toBeInTheDocument() + + // The extension ack resolves the pending state. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, filePath: "src/a.ts", success: true }, + }) + await screen.findByTestId("change-card-file-success-0") + expect(screen.getByTestId("change-card-file-success-0")).toHaveTextContent("Rolled back") + }) + + it("shows the file rollback error state on a failed ack", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-file-rollback-0")) + fireEvent.click(screen.getByText("Confirm")) + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "checkpoint not found", + }, + }) + + expect(await screen.findByTestId("change-card-file-error-0")).toHaveTextContent("Rollback failed") + }) + + it("rolls back the whole step through the checkpointRollbackStep message and shows pending + success", async () => { + renderWithExtensionState() + + // Open the confirm step for the step-level rollback. + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + expect(screen.getByTestId("change-card-step-confirm")).toBeInTheDocument() + expect(screen.getByText("Restores the previous content of this step's files.")).toBeInTheDocument() + + // Confirm sends the step message with the step's file list. + fireEvent.click(screen.getByText("Confirm")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "checkpointRollbackStep", + payload: { cardTs: 1000, checkpointId: "abc123", filePaths: ["src/a.ts", "src/b.ts"] }, + }) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + // The extension ack (per-step result carries the per-file outcomes). + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: true, + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: true }, + ], + }, + }) + expect(await screen.findByTestId("change-card-step-success")).toHaveTextContent("Step rolled back") + // Per-file rows resolve to success as well. + expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() + expect(screen.getByTestId("change-card-file-success-1")).toBeInTheDocument() + }) + + it("shows the step rollback error state with the first failing file's error", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + files: [ + { filePath: "src/a.ts", success: true }, + { filePath: "src/b.ts", success: false, error: "boom" }, + ], + }, + }) + + expect(await screen.findByTestId("change-card-step-error")).toHaveTextContent("Rollback failed") + expect(screen.getByTestId("change-card-file-error-1")).toBeInTheDocument() + expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() + }) + + it("ignores rollback results for other change cards", () => { + renderWithExtensionState() + + // Unrelated extension messages are dropped by the card's listener. + fireEvent(window, new MessageEvent("message", { data: { type: "state", text: "x" } })) + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + // A result for a different card ts must not resolve this card. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 999, success: true, files: [] }, + }) + + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + }) + + it("cancels the file and step rollback confirmations without sending a message", () => { + renderWithExtensionState() + + // File-level cancel returns to idle without a rollback message. + fireEvent.click(screen.getByTestId("change-card-file-rollback-0")) + fireEvent.click(screen.getByTestId("change-card-file-cancel-0")) + expect(screen.getByTestId("change-card-file-rollback-0")).toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "checkpointRollbackFile" })) + + // Step-level cancel returns to idle as well. + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByTestId("change-card-step-cancel")) + expect(screen.getByTestId("change-card-step-rollback")).toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "checkpointRollbackStep" })) + }) +}) + +describe("ChatRow - change_card say", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the change card for change_card messages", () => { + renderWithExtensionState( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, + ) + + expect(screen.getByTestId("change-card-header")).toHaveTextContent("2 file(s) changed this step") + }) +}) diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index 7ea12ef873..160883cd20 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -14,19 +14,25 @@ import { MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_PER_WRITE_CHECKPOINTS, + DEFAULT_CHANGE_CARD_DETAIL, + type ChangeCardDetail, } from "@roo-code/types" type CheckpointSettingsProps = HTMLAttributes & { enableCheckpoints?: boolean checkpointTimeout?: number perWriteCheckpoints?: boolean - setCachedStateField: SetCachedStateField<"enableCheckpoints" | "checkpointTimeout" | "perWriteCheckpoints"> + changeCardDetail?: ChangeCardDetail + setCachedStateField: SetCachedStateField< + "enableCheckpoints" | "checkpointTimeout" | "perWriteCheckpoints" | "changeCardDetail" + > } export const CheckpointSettings = ({ enableCheckpoints, checkpointTimeout, perWriteCheckpoints, + changeCardDetail, setCachedStateField, ...props }: CheckpointSettingsProps) => { @@ -41,6 +47,7 @@ export const CheckpointSettings = ({ section="checkpoints" label={t("settings:checkpoints.perWrite.label")}> { setCachedStateField("perWriteCheckpoints", e.target.checked) @@ -50,6 +57,22 @@ export const CheckpointSettings = ({
{t("settings:checkpoints.perWrite.description")}
+ + { + setCachedStateField("changeCardDetail", e.target.checked ? "full" : "summary") + }} + data-testid="change-card-detail-checkbox"> + {t("settings:checkpoints.changeCardDetail.label")} + +
+ {t("settings:checkpoints.changeCardDetail.description")} +
+
(({ onDone, t enableCheckpoints, checkpointTimeout, perWriteCheckpoints, + changeCardDetail, experiments, maxOpenTabsContext, maxWorkspaceFiles, @@ -413,6 +415,7 @@ const SettingsView = forwardRef(({ onDone, t enableCheckpoints: enableCheckpoints ?? false, checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, perWriteCheckpoints: perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, writeDelayMs, diffFuzzyThreshold, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, @@ -851,6 +854,7 @@ const SettingsView = forwardRef(({ onDone, t enableCheckpoints={enableCheckpoints} checkpointTimeout={checkpointTimeout} perWriteCheckpoints={perWriteCheckpoints} + changeCardDetail={changeCardDetail} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx index b08c15ace7..0ffccaea6b 100644 --- a/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/CheckpointSettings.spec.tsx @@ -13,6 +13,12 @@ vi.mock("@/i18n/TranslationContext", () => ({ if (key === "settings:checkpoints.perWrite.description") { return "Record a checkpoint snapshot after every successful file write by the agent" } + if (key === "settings:checkpoints.changeCardDetail.label") { + return "Show full diff in change cards" + } + if (key === "settings:checkpoints.changeCardDetail.description") { + return "Include the full unified diff inline in per-step change cards" + } return key }, }), @@ -122,4 +128,49 @@ describe("CheckpointSettings", () => { expect(setCachedStateField).toHaveBeenCalledWith("perWriteCheckpoints", false) }) + + it("renders the change-card detail checkbox unchecked by default when the value is unset", () => { + // The default is "summary", so the "full" checkbox is off. + render() + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + expect(checkbox).not.toBeChecked() + }) + + it("renders the change-card detail checkbox checked when the saved value is full", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + expect(checkbox).toBeChecked() + }) + + it("caches full when the user enables the change-card detail checkbox", () => { + render() + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("changeCardDetail", "full") + }) + + it("caches summary when the user disables the change-card detail checkbox", () => { + render( + , + ) + + const checkbox = screen.getByRole("checkbox", { name: "Show full diff in change cards" }) + fireEvent.click(checkbox) + + expect(setCachedStateField).toHaveBeenCalledWith("changeCardDetail", "summary") + }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index a3aa131902..767f3065c6 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -460,6 +460,47 @@ describe("SettingsView - Sound Settings", () => { ) }) + it("saves the change-card detail setting in the updateSettings payload", async () => { + // B3a: toggling the checkpoints section control updates cachedState and the + // save handler forwards the value ("full" here) to the extension host. + const { activateTab } = renderSettingsView({}) + + activateTab("checkpoints") + // The webview test env resolves i18n keys, so query the control by its + // test id rather than the translated label. + fireEvent.click(screen.getByTestId("change-card-detail-checkbox")) + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ changeCardDetail: "full" }), + }), + ), + ) + }) + + it("falls back to the summary default for change-card detail when unset", async () => { + // B3a: an unset setting is persisted with the shared default ("summary"). + // Dirty the form via the unrelated per-write control while leaving + // changeCardDetail unset (Save is disabled until the form is dirty), + // then assert the submitted payload carries the default. + const { activateTab } = renderSettingsView({}) + + activateTab("checkpoints") + fireEvent.click(screen.getByTestId("per-write-checkbox")) + fireEvent.click(screen.getByTestId("save-button")) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ changeCardDetail: "summary" }), + }), + ), + ) + }) it("shows tts slider when sound is enabled", () => { // Render once and get the activateTab helper const { activateTab, getSettingsContent } = renderSettingsView() diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 203d54f6ae..2e5d72e3fd 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Has d'iniciar sessió per utilitzar Claude Code. Vés a Configuració i fes clic a \"Iniciar sessió a Claude Code\" per autenticar-te." } }, + "changeCard": { + "header": "{{count}} fitxer(s) canviat(s) en aquest pas", + "rollbackFile": "Revertir aquest fitxer", + "rollbackStep": "Revertir el pas", + "rollbackWarning": "Restaura el contingut anterior dels fitxers d'aquest pas.", + "confirm": "Confirmar", + "cancel": "Cancel·lar", + "rollingBack": "Revertint...", + "rolledBack": "Revertit", + "stepRolledBack": "Pas revertit", + "rollbackFailed": "La revertida ha fallat" + }, "checkpoint": { "regular": "Punt de control", "initializingWarning": "Encara s'està inicialitzant el punt de control... Si això triga massa, pots desactivar els punts de control a la configuració i reiniciar la teva tasca.", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 590914a9ee..a6cbd56be6 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Du musst dich anmelden, um Claude Code zu verwenden. Gehe zu den Einstellungen und klicke auf \"Bei Claude Code anmelden\", um dich zu authentifizieren." } }, + "changeCard": { + "header": "{{count}} Datei(en) in diesem Schritt geändert", + "rollbackFile": "Diese Datei zurücksetzen", + "rollbackStep": "Schritt zurücksetzen", + "rollbackWarning": "Stellt den vorherigen Inhalt der Dateien dieses Schritts wieder her.", + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "rollingBack": "Wird zurückgesetzt...", + "rolledBack": "Zurückgesetzt", + "stepRolledBack": "Schritt zurückgesetzt", + "rollbackFailed": "Zurücksetzen fehlgeschlagen" + }, "checkpoint": { "regular": "Checkpoint", "initializingWarning": "Checkpoint wird noch initialisiert... Falls dies zu lange dauert, kannst du Checkpoints in den Einstellungen deaktivieren und deine Aufgabe neu starten.", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 1caacde55f..4da79710d1 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -189,6 +189,18 @@ "claudeCodeNotAuthenticated": "You need to sign in to use Claude Code. Go to Settings and click \"Sign in to Claude Code\" to authenticate." } }, + "changeCard": { + "header": "{{count}} file(s) changed this step", + "rollbackFile": "Rollback this file", + "rollbackStep": "Rollback step", + "rollbackWarning": "Restores the previous content of this step's files.", + "confirm": "Confirm", + "cancel": "Cancel", + "rollingBack": "Rolling back...", + "rolledBack": "Rolled back", + "stepRolledBack": "Step rolled back", + "rollbackFailed": "Rollback failed" + }, "checkpoint": { "regular": "Checkpoint", "initializingWarning": "Still initializing checkpoint... If this takes too long, you can disable checkpoints in settings and restart your task.", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 527d78aed5..656bb48366 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Debes iniciar sesión para usar Claude Code. Ve a Configuración y haz clic en \"Iniciar sesión en Claude Code\" para autenticarte." } }, + "changeCard": { + "header": "{{count}} archivo(s) cambiado(s) en este paso", + "rollbackFile": "Revertir este archivo", + "rollbackStep": "Revertir paso", + "rollbackWarning": "Restaura el contenido anterior de los archivos de este paso.", + "confirm": "Confirmar", + "cancel": "Cancelar", + "rollingBack": "Revertiendo...", + "rolledBack": "Revertido", + "stepRolledBack": "Paso revertido", + "rollbackFailed": "La reversión falló" + }, "checkpoint": { "regular": "Punto de control", "initializingWarning": "Todavía inicializando el punto de control... Si esto tarda demasiado, puedes desactivar los puntos de control en la configuración y reiniciar tu tarea.", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 638b2c0227..4bfbdefe43 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Vous devez vous connecter pour utiliser Claude Code. Allez dans les Paramètres et cliquez sur \"Se connecter à Claude Code\" pour vous authentifier." } }, + "changeCard": { + "header": "{{count}} fichier(s) modifié(s) à cette étape", + "rollbackFile": "Réinitialiser ce fichier", + "rollbackStep": "Réinitialiser l'étape", + "rollbackWarning": "Restaure le contenu précédent des fichiers de cette étape.", + "confirm": "Confirmer", + "cancel": "Annuler", + "rollingBack": "Réinitialisation...", + "rolledBack": "Réinitialisé", + "stepRolledBack": "Étape réinitialisée", + "rollbackFailed": "Échec de la réinitialisation" + }, "checkpoint": { "regular": "Point de contrôle", "initializingWarning": "Initialisation du point de contrôle en cours... Si cela prend trop de temps, tu peux désactiver les points de contrôle dans les paramètres et redémarrer ta tâche.", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 31270bc937..83f72be63b 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Claude Code का उपयोग करने के लिए आपको साइन इन करना होगा। सेटिंग्स में जाएं और प्रमाणित करने के लिए \"Claude Code में साइन इन करें\" पर क्लिक करें।" } }, + "changeCard": { + "header": "इस चरण में {{count}} फ़ाइल(ें) बदली गईं", + "rollbackFile": "इस फ़ाइल को रोलबैक करें", + "rollbackStep": "चरण रोलबैक करें", + "rollbackWarning": "यह चरण की फ़ाइलों की पिछली सामग्री पुनर्स्थापित करता है।", + "confirm": "पुष्टि करें", + "cancel": "रद्द करें", + "rollingBack": "रोलबैक हो रहा है...", + "rolledBack": "रोलबैक हो गया", + "stepRolledBack": "चरण रोलबैक हो गया", + "rollbackFailed": "रोलबैक विफल" + }, "checkpoint": { "regular": "चेकपॉइंट", "initializingWarning": "चेकपॉइंट अभी भी आरंभ हो रहा है... अगर यह बहुत समय ले रहा है, तो आप सेटिंग्स में चेकपॉइंट को अक्षम कर सकते हैं और अपने कार्य को पुनः आरंभ कर सकते हैं।", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 3b11773652..72679b15b8 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -192,6 +192,18 @@ "claudeCodeNotAuthenticated": "Anda perlu masuk untuk menggunakan Claude Code. Buka Pengaturan dan klik \"Masuk ke Claude Code\" untuk mengautentikasi." } }, + "changeCard": { + "header": "{{count}} file diubah pada langkah ini", + "rollbackFile": "Kembalikan file ini", + "rollbackStep": "Kembalikan langkah", + "rollbackWarning": "Memulihkan konten sebelumnya dari file pada langkah ini.", + "confirm": "Konfirmasi", + "cancel": "Batal", + "rollingBack": "Mengembalikan...", + "rolledBack": "Dikembalikan", + "stepRolledBack": "Langkah dikembalikan", + "rollbackFailed": "Pembalikan gagal" + }, "checkpoint": { "regular": "Checkpoint", "initializingWarning": "Masih menginisialisasi checkpoint... Jika ini terlalu lama, kamu bisa menonaktifkan checkpoint di pengaturan dan restart tugas.", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index f473b9e454..0ab7520f02 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -170,6 +170,18 @@ "claudeCodeNotAuthenticated": "Devi accedere per utilizzare Claude Code. Vai su Impostazioni e clicca su \"Accedi a Claude Code\" per autenticarti." } }, + "changeCard": { + "header": "{{count}} file modificati in questo passaggio", + "rollbackFile": "Annulla modifiche a questo file", + "rollbackStep": "Annulla modifiche del passaggio", + "rollbackWarning": "Ripristina il contenuto precedente dei file di questo passaggio.", + "confirm": "Conferma", + "cancel": "Annulla", + "rollingBack": "Annullamento...", + "rolledBack": "Annullato", + "stepRolledBack": "Passaggio annullato", + "rollbackFailed": "Annullamento non riuscito" + }, "checkpoint": { "regular": "Checkpoint", "initializingWarning": "Inizializzazione del checkpoint in corso... Se questa operazione richiede troppo tempo, puoi disattivare i checkpoint nelle impostazioni e riavviare l'attività.", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 470e66bd65..44f2020d5d 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Claude Codeを使用するにはサインインが必要です。設定に移動して「Claude Codeにサインイン」をクリックして認証してください。" } }, + "changeCard": { + "header": "このステップで {{count}} 件のファイルが変更されました", + "rollbackFile": "このファイルをロールバック", + "rollbackStep": "ステップをロールバック", + "rollbackWarning": "このステップのファイルを元のコンテンツに復元します。", + "confirm": "確認", + "cancel": "キャンセル", + "rollingBack": "ロールバック中...", + "rolledBack": "ロールバック済み", + "stepRolledBack": "ステップをロールバックしました", + "rollbackFailed": "ロールバックに失敗しました" + }, "checkpoint": { "regular": "チェックポイント", "initializingWarning": "チェックポイントの初期化中... 時間がかかりすぎる場合は、設定でチェックポイントを無効にしてタスクを再開できます。", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index c988d469dd..ec7e8339ac 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Claude Code를 사용하려면 로그인해야 합니다. 설정으로 이동하여 \"Claude Code에 로그인\"을 클릭하여 인증하세요." } }, + "changeCard": { + "header": "이 단계에서 {{count}}개 파일이 변경됨", + "rollbackFile": "이 파일 되돌리기", + "rollbackStep": "단계 되돌리기", + "rollbackWarning": "이 단계 파일의 이전 콘텐츠로 복원합니다.", + "confirm": "확인", + "cancel": "취소", + "rollingBack": "되돌리는 중...", + "rolledBack": "되돌림", + "stepRolledBack": "단계가 되돌려졌음", + "rollbackFailed": "되돌리기 실패" + }, "checkpoint": { "regular": "체크포인트", "initializingWarning": "체크포인트 초기화 중... 시간이 너무 오래 걸리면 설정에서 체크포인트를 비활성화하고 작업을 다시 시작할 수 있습니다.", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index e6f388281e..1517638dbc 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -162,6 +162,18 @@ "claudeCodeNotAuthenticated": "Je moet inloggen om Claude Code te gebruiken. Ga naar Instellingen en klik op \"Inloggen bij Claude Code\" om te authenticeren." } }, + "changeCard": { + "header": "{{count}} bestand(en) gewijzigd in deze stap", + "rollbackFile": "Dit bestand terugzetten", + "rollbackStep": "Stap terugzetten", + "rollbackWarning": "Stelt de eerdere inhoud van de bestanden van deze stap weer in.", + "confirm": "Bevestigen", + "cancel": "Annuleren", + "rollingBack": "Terugzetten...", + "rolledBack": "Teruggezet", + "stepRolledBack": "Stap teruggezet", + "rollbackFailed": "Terugzetten mislukt" + }, "checkpoint": { "regular": "Checkpoint", "initializingWarning": "Checkpoint wordt nog steeds geïnitialiseerd... Als dit te lang duurt, kun je checkpoints uitschakelen in de instellingen en je taak opnieuw starten.", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 39c2d1c9cd..07b569926f 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Musisz się zalogować, aby korzystać z Claude Code. Przejdź do Ustawień i kliknij \"Zaloguj się do Claude Code\", aby się uwierzytelnić." } }, + "changeCard": { + "header": "{{count}} plik(ów) zmienionych w tym kroku", + "rollbackFile": "Cofnij ten plik", + "rollbackStep": "Cofnij krok", + "rollbackWarning": "Przywraca poprzednią zawartość plików tego kroku.", + "confirm": "Potwierdź", + "cancel": "Anuluj", + "rollingBack": "Cofanie...", + "rolledBack": "Cofnięto", + "stepRolledBack": "Krok cofnięty", + "rollbackFailed": "Cofnięcie nie powiodło się" + }, "checkpoint": { "regular": "Punkt kontrolny", "initializingWarning": "Trwa inicjalizacja punktu kontrolnego... Jeśli to trwa zbyt długo, możesz wyłączyć punkty kontrolne w ustawieniach i uruchomić zadanie ponownie.", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 9dc67a627c..df4fb55998 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Você precisa fazer login para usar o Claude Code. Vá para Configurações e clique em \"Entrar no Claude Code\" para autenticar." } }, + "changeCard": { + "header": "{{count}} arquivo(s) alterado(s) nesta etapa", + "rollbackFile": "Reverter este arquivo", + "rollbackStep": "Reverter etapa", + "rollbackWarning": "Restaura o conteúdo anterior dos arquivos desta etapa.", + "confirm": "Confirmar", + "cancel": "Cancelar", + "rollingBack": "Revertendo...", + "rolledBack": "Revertido", + "stepRolledBack": "Etapa revertida", + "rollbackFailed": "Falha na reversão" + }, "checkpoint": { "regular": "Ponto de verificação", "initializingWarning": "Ainda inicializando ponto de verificação... Se isso demorar muito, você pode desativar os pontos de verificação nas configurações e reiniciar sua tarefa.", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 7eb863904f..1db58e0537 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -162,6 +162,18 @@ "claudeCodeNotAuthenticated": "Вам необходимо войти в систему, чтобы использовать Claude Code. Перейдите в Настройки и нажмите «Войти в Claude Code» для аутентификации." } }, + "changeCard": { + "header": "В этом шаге изменено файлов: {{count}}", + "rollbackFile": "Откатить этот файл", + "rollbackStep": "Откатить шаг", + "rollbackWarning": "Восстанавливает предыдущее содержимое файлов этого шага.", + "confirm": "Подтвердить", + "cancel": "Отмена", + "rollingBack": "Выполняется откат...", + "rolledBack": "Откат выполнен", + "stepRolledBack": "Шаг откатен", + "rollbackFailed": "Ошибка отката" + }, "checkpoint": { "regular": "Точка сохранения", "initializingWarning": "Точка сохранения еще инициализируется... Если это занимает слишком много времени, вы можете отключить точки сохранения в настройках и перезапустить задачу.", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index b6d43b4b12..8b8a862807 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Claude Code'u kullanmak için oturum açmanız gerekiyor. Ayarlar'a gidin ve kimlik doğrulaması yapmak için \"Claude Code'da Oturum Aç\" seçeneğine tıklayın." } }, + "changeCard": { + "header": "Bu adımda {{count}} dosya değiştirildi", + "rollbackFile": "Bu dosyayı geri al", + "rollbackStep": "Adımı geri al", + "rollbackWarning": "Bu adımın dosyalarının önceki içeriğini geri yükler.", + "confirm": "Onayla", + "cancel": "İptal", + "rollingBack": "Geri alınıyor...", + "rolledBack": "Geri alındı", + "stepRolledBack": "Adım geri alındı", + "rollbackFailed": "Geri alma başarısız" + }, "checkpoint": { "regular": "Kontrol Noktası", "initializingWarning": "Kontrol noktası hala başlatılıyor... Bu çok uzun sürerse, ayarlar bölümünden kontrol noktalarını devre dışı bırakabilir ve görevinizi yeniden başlatabilirsiniz.", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index e6c7ded31a..95b7b75b8e 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "Bạn cần đăng nhập để sử dụng Claude Code. Vào Cài đặt và nhấp vào \"Đăng nhập vào Claude Code\" để xác thực." } }, + "changeCard": { + "header": "{{count}} tệp đã thay đổi trong bước này", + "rollbackFile": "Hoàn tác tệp này", + "rollbackStep": "Hoàn tác bước", + "rollbackWarning": "Khôi phục nội dung trước đó của các tệp trong bước này.", + "confirm": "Xác nhận", + "cancel": "Hủy", + "rollingBack": "Đang hoàn tác...", + "rolledBack": "Đã hoàn tác", + "stepRolledBack": "Đã hoàn tác bước", + "rollbackFailed": "Hoàn tác thất bại" + }, "checkpoint": { "regular": "Điểm kiểm tra", "initializingWarning": "Đang khởi tạo điểm kiểm tra... Nếu quá trình này mất quá nhiều thời gian, bạn có thể vô hiệu hóa điểm kiểm tra trong cài đặt và khởi động lại tác vụ của bạn.", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index bc8f5dba93..573e1a9092 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -167,6 +167,18 @@ "claudeCodeNotAuthenticated": "你需要登录才能使用 Claude Code。前往设置并点击「登录到 Claude Code」进行身份验证。" } }, + "changeCard": { + "header": "此步骤中更改了 {{count}} 个文件", + "rollbackFile": "回退此文件", + "rollbackStep": "回退此步骤", + "rollbackWarning": "将恢复此步骤文件的上一版本内容。", + "confirm": "确认", + "cancel": "取消", + "rollingBack": "正在回退...", + "rolledBack": "已回退", + "stepRolledBack": "步骤已回退", + "rollbackFailed": "回退失败" + }, "checkpoint": { "regular": "检查点", "initializingWarning": "正在初始化检查点...如果耗时过长,你可以在设置中禁用检查点并重新启动任务。", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index d2fe37a774..f5054e5ec5 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -189,6 +189,18 @@ "claudeCodeNotAuthenticated": "您需要登入才能使用 Claude Code。前往設定並點選「登入 Claude Code」以進行驗證。" } }, + "changeCard": { + "header": "此步驟中變更了 {{count}} 個檔案", + "rollbackFile": "還原此檔案", + "rollbackStep": "還原此步驟", + "rollbackWarning": "將還原此步驟檔案的上一版內容。", + "confirm": "確認", + "cancel": "取消", + "rollingBack": "正在還原...", + "rolledBack": "已還原", + "stepRolledBack": "步驟已還原", + "rollbackFailed": "還原失敗" + }, "checkpoint": { "regular": "檢查點", "initializingWarning": "正在初始化檢查點... 如果耗時過長,您可以在 設定 中停用檢查點並重新啟動工作。", From 6a0feb131d1eefeed3e06f4a910ac69e0e7ff341 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 08:28:00 +0800 Subject: [PATCH 28/46] test(trial): reconcile write-tool specs with the composed L2 default (addendum) The composed tree includes L2 (chat-diff is the default approval path, preventFocusDisruption now defaults to true) alongside the B-stack and S4b. The L2 spec helper pins the extension state via a providerRef override, which clobbered per-test state and forced the chat-diff branch on suites written against the legacy diff-editor path. Trial addendum (review-convenience only; the component PRs stay the review units): - the writeToFile spec helper now pins the legacy path by default and accepts experiments/state options so individual tests opt in or set extension state without being clobbered - the L2 routing tests and the B1 per-write-checkpoint tests thread their scenario through those options - the B1 prevent-focus-disruption test asserts the S4b six-argument saveDirectly call (guarded-write kind appended) - the applyPatch execute spec pins the legacy path in its default mock task state; the PFD-branch test opts in explicitly --- .../__tests__/applyPatchTool.execute.spec.ts | 5 +- .../tools/__tests__/writeToFileTool.spec.ts | 72 +++++++------------ 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 761c1562d0..64f6adace0 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -80,7 +80,10 @@ describe("ApplyPatchTool.execute - delete file success path", () => { consecutiveMistakeCount: 0, providerRef: { deref: vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({}), + // Trial addendum: pin the legacy diff-editor path; the L2 default flipped + // preventFocusDisruption to true (chat-diff is the default approval path). + // The PFD-branch test below opts in explicitly. + getState: vi.fn().mockResolvedValue({ experiments: { preventFocusDisruption: false } }), }), } as unknown as Task["providerRef"], recordToolUsage: vi.fn(), diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index a169c0283f..01ed871ab8 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -232,6 +232,10 @@ describe("writeToFileTool", () => { isPartial?: boolean accessAllowed?: boolean experiments?: Record + // Trial addendum: extra extension-state fields merged into the pinned state + // (perWriteCheckpoints, auto-approval settings, ...) so the helper's + // providerRef pin does not clobber per-test state. + state?: Record } = {}, ): Promise { // Configure mocks based on test scenario @@ -245,7 +249,12 @@ describe("writeToFileTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: options.experiments ?? {}, + // Trial addendum: pin the legacy diff-editor path for the pre-existing suites; + // the L2 default flipped preventFocusDisruption to true (chat-diff is the + // default approval path). Tests exercising the chat-diff branch opt in via + // the experiments option. + experiments: options.experiments ?? { preventFocusDisruption: false }, + ...options.state, }), }) @@ -403,16 +412,10 @@ describe("writeToFileTool", () => { it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { mockAskApproval.mockResolvedValue(true) // No stored experiment value: the default (flipped to true in L2) resolves - // to the chat-diff path. - mockCline.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: {}, - }), - }) - - await executeWriteFileTool() + // to the chat-diff path. Trial addendum: the empty experiments object is + // threaded through the helper (its providerRef pin would otherwise clobber + // this test's stored-value scenario). + await executeWriteFileTool({}, { experiments: {} }) expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() @@ -423,15 +426,8 @@ describe("writeToFileTool", () => { it("saves via saveDirectly when the user has explicitly enabled the experiment", async () => { mockAskApproval.mockResolvedValue(true) // Explicit stored true: same chat-diff routing as the default. - mockCline.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: { preventFocusDisruption: true }, - }), - }) - - await executeWriteFileTool() + // Trial addendum: threaded through the helper's experiments option. + await executeWriteFileTool({}, { experiments: { preventFocusDisruption: true } }) expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() @@ -621,13 +617,9 @@ describe("writeToFileTool", () => { }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { - mockCline.providerRef.deref = vi.fn().mockReturnValue({ - getState: vi - .fn() - .mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, perWriteCheckpoints: false }), - }) - - await executeWriteFileTool({}) + // Trial addendum: thread the state through the helper (its providerRef pin + // would otherwise clobber this test's extension state). + await executeWriteFileTool({}, { state: { perWriteCheckpoints: false } }) expect(mockCline.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).not.toHaveBeenCalled() @@ -682,15 +674,9 @@ describe("writeToFileTool", () => { }) it("threads write info with approval diff stats when the prevent-focus-disruption experiment is enabled", async () => { - mockCline.providerRef.deref = vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: { preventFocusDisruption: true }, - }), - }) - - await executeWriteFileTool({}) + // Trial addendum: opt into the chat-diff branch via the helper's experiments + // option (the helper now pins the legacy path by default). + await executeWriteFileTool({}, { experiments: { preventFocusDisruption: true } }) // The experiment branch saves directly (no diff view) and still // journals the write through the same single checkpoint hook, carrying @@ -701,6 +687,8 @@ describe("writeToFileTool", () => { false, true, 1000, + // Trial addendum: S4b appends the guarded-write kind to the save call. + "create", ) expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { path: testFilePath, @@ -727,16 +715,8 @@ describe("writeToFileTool", () => { it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { // B3a: when the step is auto-approved the checkpoint write carries // autoApproved so checkpointSave can force the compact change card. - mockCline.providerRef.deref = vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - autoApprovalEnabled: true, - alwaysAllowWrite: true, - }), - }) - - await executeWriteFileTool({}) + // Trial addendum: thread the auto-approval state through the helper. + await executeWriteFileTool({}, { state: { autoApprovalEnabled: true, alwaysAllowWrite: true } }) expect(mockedCheckpointSave).toHaveBeenCalledWith(mockCline, false, true, { path: testFilePath, From 376e013fafa67dbabf0837cbc6dbdcb9d816cf38 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 08:49:52 +0800 Subject: [PATCH 29/46] test(trial): compose the L2 default with the edit/write specs and record apply_diff's own read (addendum 2) The L2 merge flipped preventFocusDisruption to true in src/shared/experiments.ts. The helper providerRef pins in the edit_file / edit / search_replace suites resolved per-test state to that new default, routing legacy-path tests onto the chat-diff path and clobbering per-test extension state (18 CI failures on the composed head). Addendum 2 pins the legacy path as the helper default, threads per-test experiments/state through helper options (the same shape addendum 1 used for write_file / apply_patch), and rewrites the L2 routing tests accordingly. In the real extension, apply_diff reads the file itself before the guarded edit publish, but never recorded an S2 observation, so the composed PFD default rejected every apply_diff e2e write as an unobserved edit (the mocked conversations carry no read_file step). ApplyDiffTool now records its own read under the ReadFileTool pre/post token-match contract before the publish, with unit coverage for the observation and the disagreeing-token case. --- src/core/tools/ApplyDiffTool.ts | 15 +++++ .../applyDiffTool.guardedWrite.spec.ts | 64 +++++++++++++++++-- src/core/tools/__tests__/editFileTool.spec.ts | 61 ++++++------------ src/core/tools/__tests__/editTool.spec.ts | 23 +++---- .../tools/__tests__/searchReplaceTool.spec.ts | 34 ++++------ 5 files changed, 120 insertions(+), 77 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 9e6f87f653..231fab602a 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -13,6 +13,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { versionTokenOfStat } from "../../utils/versionToken" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -68,7 +69,21 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } + // S4b (trial addendum): apply_diff reads the file itself to apply the patch. + // Record that read as an S2 observation under the ReadFileTool contract + // (observed only when the pre- and post-read tokens agree) so the guarded + // edit publish in the prevent-focus-disruption branch can compare-and-swap + // against the token this tool observed, instead of being rejected as an + // unobserved edit. + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) const originalContent: string = await fs.readFile(absolutePath, "utf-8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(absolutePath, preReadToken) + } + } // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts index 2181c98941..7f8f6721d6 100644 --- a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -2,15 +2,33 @@ import type { MockedFunction } from "vitest" +import * as fsPromises from "fs/promises" +import path from "path" + import { fileExistsAtPath } from "../../../utils/fs" import type { Task } from "../../task/Task" import { ApplyDiffTool } from "../ApplyDiffTool" -vi.mock("fs/promises", () => ({ - default: { - readFile: vi.fn().mockResolvedValue("original file content\n"), - }, -})) +vi.mock("fs/promises", () => { + // BigInt stats so the S4b observation (ReadFileTool contract) can build a + // deterministic version token. Stable values = pre/post tokens agree. The + // named `stat` export is the same double as `default.stat` (the tool imports + // the default namespace; the spec asserts through the named one). + const statMock = vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }) + return { + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + stat: statMock, + }, + stat: statMock, + } +}) vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(true), @@ -49,8 +67,10 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { | "diffViewProvider" | "providerRef" | "fileContextTracker" + | "observationRegistry" > let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockObserve: MockedFunction<(...args: unknown[]) => void> let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> @@ -66,6 +86,10 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { finalContent: "new content", }) + // S2 observation registry double: the tool records its own read here before + // the guarded edit publish (S4b trial addendum). + mockObserve = vi.fn() + // Structural stubs for the guarded-write path: the real DiffViewProvider is // out of scope here, so vi.fn() doubles stand in for the members the tool // touches (the saveDirectly double also records the writeKind plumbing). @@ -110,6 +134,9 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined), } as unknown as Task["fileContextTracker"], + observationRegistry: { + observe: mockObserve, + } as unknown as Task["observationRegistry"], } mockAskApproval = vi.fn().mockResolvedValue(true) @@ -134,11 +161,38 @@ describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { 1000, "edit", ) + // S4b (trial addendum): the tool's own read is recorded as an observation so + // the guarded edit publish can CAS against the observed token. + expect(mockObserve).toHaveBeenCalledTimes(1) + expect(mockObserve.mock.calls[0]?.[0]).toBe(path.resolve(mockTask.cwd, "src/thing.ts")) + expect(mockObserve.mock.calls[0]?.[1]).toBe("7:4242:1234:1700000000123456789:1700000000789999999") expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") expect(mockTask.didEditFile).toBe(true) expect(mockHandleError).not.toHaveBeenCalled() }) + it("does not observe when the pre- and post-read tokens disagree", async () => { + // A mutation between the two stats means the content the diff was applied + // to is not the on-disk state: leave the target unobserved (ReadFileTool + // contract) so the guarded write gets a stale rejection, not a false match. + const preStats = { dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n } + const postStats = { dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n } + const mockedStat = vi.mocked(fsPromises.stat) + mockedStat.mockResolvedValueOnce(preStats as unknown as Awaited>) + mockedStat.mockResolvedValueOnce(postStats as unknown as Awaited>) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockObserve).not.toHaveBeenCalled() + // The publish still runs (the saveDirectly double stands in for the guard); + // the registry simply carries no observation for this path. + expect(mockSaveDirectly).toHaveBeenCalled() + }) + it("surfaces the unobserved edit remediation as a tool error", async () => { const guardError = new Error("File not read yet -- read the file, then retry.") mockSaveDirectly.mockRejectedValue(guardError) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 3f7cf6399e..b4430c410f 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -182,6 +182,10 @@ describe("editFileTool", () => { isPartial?: boolean accessAllowed?: boolean experiments?: Record + // Trial addendum: extra extension-state fields merged into the pinned state + // (perWriteCheckpoints, auto-approval settings, ...) so the helper's + // providerRef pin does not clobber per-test state. + state?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -196,7 +200,11 @@ describe("editFileTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: options.experiments ?? {}, + // Trial addendum: pin the legacy diff-editor path for the pre-existing suites; + // the L2 default flipped preventFocusDisruption to true. Tests exercising + // the chat-diff branch opt in via the experiments option. + experiments: options.experiments ?? { preventFocusDisruption: false }, + ...options.state, }), }) @@ -601,16 +609,10 @@ describe("editFileTool", () => { it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { mockAskApproval.mockResolvedValue(true) // No stored experiment value: the default (flipped to true in L2) resolves - // to the chat-diff path. - mockTask.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: {}, - }), - }) - - await executeEditFileTool() + // to the chat-diff path. Trial addendum: the empty experiments object is + // threaded through the helper (its providerRef pin would otherwise clobber + // this test's stored-value scenario). + await executeEditFileTool({}, { experiments: {} }) expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockTask.diffViewProvider.open).not.toHaveBeenCalled() @@ -621,15 +623,8 @@ describe("editFileTool", () => { it("saves via saveDirectly when the user has explicitly enabled the experiment", async () => { mockAskApproval.mockResolvedValue(true) // Explicit stored true: same chat-diff routing as the default. - mockTask.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: { preventFocusDisruption: true }, - }), - }) - - await executeEditFileTool() + // Trial addendum: threaded through the helper's experiments option. + await executeEditFileTool({}, { experiments: { preventFocusDisruption: true } }) expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockTask.diffViewProvider.open).not.toHaveBeenCalled() @@ -944,16 +939,9 @@ describe("editFileTool", () => { }) it("does not record a checkpoint when perWriteCheckpoints is disabled", async () => { - mockTask.providerRef.deref = vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: {}, - perWriteCheckpoints: false, - }), - }) - - await executeEditFileTool({}) + // Trial addendum: thread the state through the helper (its providerRef pin + // would otherwise clobber this test's extension state). + await executeEditFileTool({}, { state: { perWriteCheckpoints: false } }) expect(mockTask.consecutiveMistakeCount).toBe(0) expect(mockedCheckpointSave).not.toHaveBeenCalled() @@ -1000,17 +988,8 @@ describe("editFileTool", () => { it("threads autoApproved into the checkpoint write for auto-approved steps", async () => { // B3a: when the step is auto-approved the checkpoint write carries // autoApproved so checkpointSave can force the compact change card. - mockTask.providerRef.deref = vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: {}, - autoApprovalEnabled: true, - alwaysAllowWrite: true, - }), - }) - - await executeEditFileTool({}) + // Trial addendum: thread the auto-approval state through the helper. + await executeEditFileTool({}, { state: { autoApprovalEnabled: true, alwaysAllowWrite: true } }) expect(mockedCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { path: testFilePath, diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index 198abb0f9e..e35f21c157 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -172,6 +172,9 @@ describe("editTool", () => { isPartial?: boolean accessAllowed?: boolean experiments?: Record + // Trial addendum: extra extension-state fields merged into the pinned state + // so the helper's providerRef pin does not clobber per-test state. + state?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -186,7 +189,11 @@ describe("editTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: options.experiments ?? {}, + // Trial addendum: pin the legacy diff-editor path for the pre-existing suites; + // the L2 default flipped preventFocusDisruption to true. Tests exercising + // the chat-diff branch opt in via the experiments option. + experiments: options.experiments ?? { preventFocusDisruption: false }, + ...options.state, }), }) @@ -375,16 +382,10 @@ describe("editTool", () => { it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { mockAskApproval.mockResolvedValue(true) // No stored experiment value: the default (flipped to true in L2) resolves - // to the chat-diff path. - mockTask.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: {}, - }), - }) - - await executeEditTool() + // to the chat-diff path. Trial addendum: the empty experiments object is + // threaded through the helper (its providerRef pin would otherwise clobber + // this test's stored-value scenario). + await executeEditTool({}, { experiments: {} }) expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockTask.diffViewProvider.open).not.toHaveBeenCalled() diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index e83c9c8133..658dc975b9 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -169,6 +169,9 @@ describe("searchReplaceTool", () => { isPartial?: boolean accessAllowed?: boolean experiments?: Record + // Trial addendum: extra extension-state fields merged into the pinned state + // so the helper's providerRef pin does not clobber per-test state. + state?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -183,7 +186,11 @@ describe("searchReplaceTool", () => { getState: vi.fn().mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, - experiments: options.experiments ?? {}, + // Trial addendum: pin the legacy diff-editor path for the pre-existing suites; + // the L2 default flipped preventFocusDisruption to true. Tests exercising + // the chat-diff branch opt in via the experiments option. + experiments: options.experiments ?? { preventFocusDisruption: false }, + ...options.state, }), }) @@ -344,16 +351,10 @@ describe("searchReplaceTool", () => { it("saves via saveDirectly without opening the diff editor when no experiment value is stored", async () => { mockAskApproval.mockResolvedValue(true) // No stored experiment value: the default (flipped to true in L2) resolves - // to the chat-diff path. - mockCline.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: {}, - }), - }) - - await executeSearchReplaceTool() + // to the chat-diff path. Trial addendum: the empty experiments object is + // threaded through the helper (its providerRef pin would otherwise clobber + // this test's stored-value scenario). + await executeSearchReplaceTool({}, { experiments: {} }) expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() @@ -364,15 +365,8 @@ describe("searchReplaceTool", () => { it("saves via saveDirectly when the user has explicitly enabled the experiment", async () => { mockAskApproval.mockResolvedValue(true) // Explicit stored true: same chat-diff routing as the default. - mockCline.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - diagnosticsEnabled: true, - writeDelayMs: 1000, - experiments: { preventFocusDisruption: true }, - }), - }) - - await executeSearchReplaceTool() + // Trial addendum: threaded through the helper's experiments option. + await executeSearchReplaceTool({}, { experiments: { preventFocusDisruption: true } }) expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalled() expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() From 178e6f405fb92e3bfcaca5ededcb6d9dafcc299f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 09:23:27 +0800 Subject: [PATCH 30/46] fix(fws): address CodeRabbit findings on the trial aggregate - apply_patch: record the S2 self-read observation from the hunk read (pre/post stat contract), so the in-place modify publish is not rejected as an unobserved write under the chat-diff default. Regression tests pin the observe and the token-mismatch skip. - checkpoints: restoreFile now verifies the checkpoint object with rev-parse --verify before the exists-at-commit lookup; an unavailable checkpoint fails loudly instead of routing into the file-delete branch (simple-git raw resolves silently when git exits non-zero without stderr, so the evidence must come from rev-parse output). Regression test pins the no-delete guarantee. - mcp: the mcp_settings merge callback preserves only a plain mcpServers object (arrays no longer satisfy the typeof object check); the spec mock mirrors the production merge contract (only ENOENT/SyntaxError are recoverable, other I/O errors reject before the merge). - safeWriteJson test: replace the hoisted vi.unmock (which cannot remove a runtime vi.doMock) with doUnmock + resetModules in both cleanup sites. - webview: writeDelayMs placeholder now uses the shared DEFAULT_WRITE_DELAY_MS instead of a hard-coded 1000; ChangeCard resolves the step state from a failure result that carries no per-file payload (missing-task response) and uses the v4 grow utility; CheckpointSettings renders changeCardDetail as a sibling of perWriteCheckpoints; fix the Catalan rollbackFailed string. --- src/core/tools/ApplyPatchTool.ts | 20 ++++++- .../__tests__/applyPatchTool.execute.spec.ts | 53 +++++++++++++++++++ .../checkpoints/ShadowCheckpointService.ts | 16 ++++++ .../__tests__/ShadowCheckpointService.spec.ts | 17 ++++++ src/services/mcp/McpHub.ts | 11 +++- src/services/mcp/__tests__/McpHub.spec.ts | 44 ++++++++++++++- src/utils/__tests__/safeWriteJson.test.ts | 11 +++- webview-ui/src/components/chat/ChangeCard.tsx | 11 ++-- .../chat/__tests__/ChangeCard.spec.tsx | 40 ++++++++++++++ .../settings/CheckpointSettings.tsx | 33 ++++++------ .../src/context/ExtensionStateContext.tsx | 6 ++- .../__tests__/ExtensionStateContext.spec.tsx | 8 ++- webview-ui/src/i18n/locales/ca/chat.json | 2 +- 13 files changed, 243 insertions(+), 29 deletions(-) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 94c5c45be6..bf6413de3c 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -13,6 +13,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { versionTokenOfStat } from "../../utils/versionToken" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -99,10 +100,25 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each hunk + // Process each hunk. The read doubles as the S2 observation for the + // guarded publish (ReadFileTool contract: stat before and after the + // read, observe only when the on-disk version is unchanged between the + // two stats). Without it, the in-place modify publish is an unobserved + // write and the composed chat-diff default rejects it ("File already + // exists ... and was not read before this write") even though this tool + // just read the exact content the patch was applied to. const readFile = async (filePath: string): Promise => { const absolutePath = path.resolve(task.cwd, filePath) - return await fs.readFile(absolutePath, "utf8") + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const content: string = await fs.readFile(absolutePath, "utf8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(absolutePath, preReadToken) + } + } + return content } let changes: ApplyPatchFileChange[] diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 64f6adace0..3c1af124fd 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,9 +4,11 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import path from "path" import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" import { checkpointSave } from "../../checkpoints" +import { ObservationRegistry } from "../../task/observationRegistry" import { ApplyPatchTool } from "../ApplyPatchTool" // The vi.mock factory exposes the fs/promises functions under a `default` @@ -15,6 +17,7 @@ import { ApplyPatchTool } from "../ApplyPatchTool" const mockedFsPromises = vi.mocked( fsPromises as unknown as { default: { + stat: ReturnType unlink: MockedFunction writeFile: MockedFunction } @@ -24,6 +27,15 @@ const mockedFsPromises = vi.mocked( vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), + // Stable on-disk version for the S2 self-read observation (the hunk + // read now stats before and after; equal tokens record the observe). + stat: vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }), unlink: vi.fn().mockResolvedValue(undefined), mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), @@ -64,6 +76,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "providerRef" | "diffViewProvider" | "fileContextTracker" + | "observationRegistry" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -78,6 +91,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ // Trial addendum: pin the legacy diff-editor path; the L2 default flipped @@ -732,6 +746,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { | "diffViewProvider" | "providerRef" | "fileContextTracker" + | "observationRegistry" > let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> @@ -783,6 +798,7 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolError: vi.fn(), rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true), @@ -836,6 +852,43 @@ describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { expect(mockHandleError).not.toHaveBeenCalled() }) + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // The hunk read doubles as the S2 observation (ReadFileTool contract): + // stable pre/post stats record the version token, so the in-place modify + // publish is not rejected as an unobserved write. + const observed = mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts")) + expect(observed?.version).toBe("7:4242:1234:1700000000123456789:1700000000789999999") + }) + + it("update: does not observe when the pre- and post-read tokens disagree", async () => { + // The file changed mid-read: pre/post stats differ, so no observation is + // recorded and the guarded publish surfaces the unobserved-existing + // remediation instead of publishing against a stale version. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n }) + + const guardError = new Error( + "File already exists at /workspace/project/src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + expect(mockHandleError).toHaveBeenCalled() + }) + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { mockedFileExistsAtPath.mockResolvedValueOnce(false) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 203d1c4727..9ca77013d8 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -466,6 +466,22 @@ export abstract class ShadowCheckpointService extends EventEmitter { /** Whether `filePath` exists in the tree of `commitHash`. */ private async fileExistsInCommit(commitHash: string, filePath: string): Promise { + // A failed lookup is not evidence the file is absent. If the commit object + // itself cannot be read (invalid hash, corrupt or missing shadow repo), + // falling through to the restoreFile delete branch would remove a live + // file. Verify the object first and fail the restore loudly instead. + // + // Verification must be evidence-based: simple-git's raw() only rejects + // when git writes a fatal to stderr, and `git cat-file -e ` + // fails *silently* (exit 1, no output) — a silent resolution would be + // read as "valid commit". `rev-parse --verify` emits the resolved id on + // success and a stderr fatal on every failure mode, so the reject is + // reliable. + try { + await this.git!.raw(["rev-parse", "--verify", `${commitHash}^{commit}`]) + } catch { + throw new Error(`Checkpoint unavailable: ${commitHash}`) + } try { await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`]) return true diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 0b9a48cb75..7245fb2ede 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -243,6 +243,23 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( ) }) + it("rejects an unavailable checkpoint instead of deleting the live file", async () => { + // A corrupt journal entry can hand the rollback service a checkpoint + // id that does not resolve to a shadow-repo object. The lookup + // failure must not be read as "file absent at the checkpoint" — + // that would route the restore into the delete branch and remove a + // live file. The restore must fail loudly and leave the file intact. + await fs.writeFile(testFile, "Ahoy, world!") + await service.saveCheckpoint("First checkpoint") + + await expect( + service.restoreFile("0000000000000000000000000000000000000000", "test.txt"), + ).rejects.toThrow("Checkpoint unavailable") + + expect(await fileExistsAtPath(testFile)).toBe(true) + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + }) + it("emits a restore event when a file is restored", async () => { const restoreListener = vi.fn() service.on("restore", restoreListener) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 5d6c31a3e5..42786cfaa5 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -517,7 +517,16 @@ export class McpHub { prettyPrint: true, merge: (existing) => { const parsed = existing as { mcpServers?: unknown } | null - if (parsed && parsed.mcpServers && typeof parsed.mcpServers === "object") { + // Arrays satisfy `typeof === "object"` but are not a valid + // mcpServers map; preserve only a plain object, otherwise the + // file would be rewritten with a value McpSettingsSchema + // rejects on the next load. + if ( + parsed && + parsed.mcpServers && + !Array.isArray(parsed.mcpServers) && + typeof parsed.mcpServers === "object" + ) { return existing } return { mcpServers: {} } diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 1ff277a761..2a09fd84fa 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -52,7 +52,14 @@ vi.mock("../../../utils/safeWriteJson", () => ({ try { const fs = await import("fs/promises") existing = JSON.parse(await fs.readFile(filePath, "utf8")) - } catch { + } catch (error) { + // Mirror the production safeWriteJson merge contract: only ENOENT + // and SyntaxError are recoverable; an EACCES or I/O failure must + // reject before the merge callback runs. + const code = (error as { code?: string })?.code + if (!(error instanceof SyntaxError) && code !== "ENOENT") { + throw error + } existing = null } value = options.merge(existing, data) @@ -312,6 +319,41 @@ describe("McpHub", () => { const [, writtenData] = vi.mocked(fs.writeFile).mock.calls[0] expect(JSON.parse(writtenData as string)).toEqual({ mcpServers: {} }) }) + + it("writes the default stub when the existing mcpServers value is an array", async () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + + // Arrays satisfy `typeof === "object"`; an mcpServers map must be a + // plain object, so an array is invalid and replaced by the stub + // instead of being preserved and rejected by McpSettingsSchema later. + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ mcpServers: [] })) + + await mcpHub.getMcpSettingsFilePath() + + expect(fs.writeFile).toHaveBeenCalledTimes(1) + const [, writtenData] = vi.mocked(fs.writeFile).mock.calls[0] + expect(JSON.parse(writtenData as string)).toEqual({ mcpServers: {} }) + }) + + it("rejects creation when the locked read fails with an I/O error (EACCES)", async () => { + const settingsPath = path.join("/mock/settings/path", "mcp_settings.json") + + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + // The locked read fails with a real I/O error (not ENOENT): the + // safeWriteJson mock mirrors the production contract — reject before + // the merge callback runs instead of treating the file as absent. + vi.mocked(fs.readFile).mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }), + ) + + await expect(mcpHub.getMcpSettingsFilePath()).rejects.toThrow("EACCES: permission denied") + expect(fs.writeFile).not.toHaveBeenCalled() + }) }) describe("Discriminated union type handling", () => { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 631fb9f810..a52cfef8b3 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -390,7 +390,10 @@ describe("safeWriteJson", () => { // Clean up await fs.unlink(lockTestFilePath).catch(() => {}) // Ignore errors if file doesn't exist - vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + // A hoisted vi.unmock runs before this test's runtime vi.doMock, so it + // cannot remove it; doUnmock + resetModules clear the registry entry. + vi.doUnmock("proper-lockfile") + vi.resetModules() }) test("should release lock even if an error occurs mid-operation", async () => { const data = { message: "test lock release on error" } @@ -653,7 +656,11 @@ describe("safeWriteJson", () => { expect.any(Error), ) - vi.unmock("proper-lockfile") // Ensure the mock is removed after this test + // The hoisted vi.unmock runs before this test's runtime vi.doMock, so it + // cannot remove it; doUnmock + resetModules clear the registry entry so + // later test files import the real proper-lockfile. + vi.doUnmock("proper-lockfile") + vi.resetModules() }) // CWE-732 regression: safeWriteJson stages the temp itself and passes it diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx index aa863e7a33..92b62d4a89 100644 --- a/webview-ui/src/components/chat/ChangeCard.tsx +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -90,6 +90,11 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { setStepRollback( result.success ? successState : { status: "error", error: firstFailure?.error ?? result.error }, ) + } else if (filePath === undefined) { + // Step-level result with no per-file payload (for example the + // missing-task response: success: false, no files). Without this the + // step button would stay in the in-progress state forever. + setStepRollback(result.success ? successState : { status: "error", error: result.error }) } } window.addEventListener("message", handler) @@ -274,13 +279,13 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { {t("chat:changeCard.header", { count: card.totalFiles })} - + {stepRollbackControls()}
{card.files.map((file, index) => (
-
+
{file.diff != null ? ( { {formatPathTooltip(file.path)} - + {diffBadges(file.additions, file.deletions)}
)} diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx index 78ba90a17c..4fe11a5dc5 100644 --- a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -251,6 +251,46 @@ describe("ChangeCard", () => { expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() }) + it("resolves the step state from a failure result that carries no files", async () => { + // The missing-task response is a step-level result with success: false + // and no per-file payload (no files, no filePath). Without handling the + // empty shape the step button would stay in the pending state forever. + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "Checkpoints are not enabled for this task", + }, + }) + + // The error detail rides in the tooltip content; the visible state is + // the rollback-failed label. The assertion that matters here is that the + // step left the pending state at all (previously it would stay pending). + expect(await screen.findByTestId("change-card-step-error")).toHaveTextContent("Rollback failed") + }) + + it("keeps the step in the pending state until a no-files success resolves it", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, success: true }, + }) + + expect(await screen.findByTestId("change-card-step-success")).toBeInTheDocument() + }) + it("ignores rollback results for other change cards", () => { renderWithExtensionState() diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index 160883cd20..41475c152c 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -57,22 +57,23 @@ export const CheckpointSettings = ({
{t("settings:checkpoints.perWrite.description")}
- - { - setCachedStateField("changeCardDetail", e.target.checked ? "full" : "summary") - }} - data-testid="change-card-detail-checkbox"> - {t("settings:checkpoints.changeCardDetail.label")} - -
- {t("settings:checkpoints.changeCardDetail.description")} -
-
+ + + + { + setCachedStateField("changeCardDetail", e.target.checked ? "full" : "summary") + }} + data-testid="change-card-detail-checkbox"> + {t("settings:checkpoints.changeCardDetail.label")} + +
+ {t("settings:checkpoints.changeCardDetail.description")} +
({ changeCardDetail: "summary", checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds language: "en", // Default language code - writeDelayMs: 1000, + // Placeholder before the extension state hydrates: use the shared default + // (the pre-hydration value must not disagree with the extension's own + // DEFAULT_WRITE_DELAY_MS fallback). + writeDelayMs: DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: 4000, mcpEnabled: true, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 38d3b00f0b..a031424a50 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -409,7 +409,9 @@ describe("mergeExtensionState", () => { enableCheckpoints: true, perWriteCheckpoints: true, changeCardDetail: "summary", - writeDelayMs: 1000, + // Matches the shared DEFAULT_WRITE_DELAY_MS (pre-hydration placeholder + // must not disagree with the extension's own fallback). + writeDelayMs: 0, mode: "default", experiments: {} as Record, customModes: [], @@ -495,7 +497,9 @@ describe("mergeExtensionState", () => { enableCheckpoints: true, perWriteCheckpoints: true, changeCardDetail: "summary", - writeDelayMs: 1000, + // Matches the shared DEFAULT_WRITE_DELAY_MS (pre-hydration placeholder + // must not disagree with the extension's own fallback). + writeDelayMs: 0, mode: "default", experiments: {} as Record, customModes: [], diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 2e5d72e3fd..9438c99922 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -177,7 +177,7 @@ "rollingBack": "Revertint...", "rolledBack": "Revertit", "stepRolledBack": "Pas revertit", - "rollbackFailed": "La revertida ha fallat" + "rollbackFailed": "La reversió ha fallat" }, "checkpoint": { "regular": "Punt de control", From d2239ceb59d0fa6b4438413ba43a8aadb3ef8cdf Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 09:26:32 +0800 Subject: [PATCH 31/46] fix(fws): contain symlinked restoreFile targets (CodeRabbit security follow-up) The lexical workspace containment check cannot see through a symlinked ancestor: a link inside the workspace pointing outside it passes the prefix check while the real target resolves elsewhere. When the restore target file currently exists (the destructive case), resolve both the workspace root and the target with fs.realpath and re-check containment before any mutation; resolving both sides keeps a legitimate symlinked workspace root working. Regression: rejects a target that escapes the workspace through a symlinked ancestor (POSIX). --- .../checkpoints/ShadowCheckpointService.ts | 17 ++++++++++ .../__tests__/ShadowCheckpointService.spec.ts | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index 9ca77013d8..a4d5a56756 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -444,6 +444,23 @@ export abstract class ShadowCheckpointService extends EventEmitter { throw new Error(`restoreFile target is outside the workspace: ${filePath}`) } + // The lexical check cannot see through a symlinked ancestor: a link + // inside the workspace pointing outside it passes the prefix check + // while the real target resolves elsewhere. Re-check containment on + // the resolved (real) paths whenever the target file currently + // exists — that is the destructive case. A target that does not exist + // cannot be deleted, and the checkout branch only writes files the + // task-owned shadow repo recorded. Resolving both sides keeps a + // legitimate symlinked workspace root working. + if (await fileExistsAtPath(resolvedTarget)) { + const realWorkspaceRoot = await fs.realpath(this.workspaceDir) + const realRoot = realWorkspaceRoot.endsWith(path.sep) ? realWorkspaceRoot : realWorkspaceRoot + path.sep + const realTarget = await fs.realpath(resolvedTarget) + if (realTarget !== realWorkspaceRoot && !realTarget.startsWith(realRoot)) { + throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`) + } + } + const start = Date.now() const existed = await this.fileExistsInCommit(commitHash, gitPath) diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 7245fb2ede..f1e11ea003 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -260,6 +260,38 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") }) + it("rejects a target that escapes the workspace through a symlinked ancestor", async () => { + // A lexical prefix check passes for a path that goes through a + // symlink inside the workspace pointing outside it; the resolved + // (real) target must be contained as well, or the delete/checkout + // branch would mutate a file the task never owned (CWE-22). + if (process.platform === "win32") { + // Creating symlinks needs elevated privileges on Windows; the + // lexical-containment test above still covers the portable case. + return + } + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const outsideDir = path.join(tmpDir, `outside-${Date.now()}`) + await fs.mkdir(outsideDir, { recursive: true }) + const outsideFile = path.join(outsideDir, "sneaky.txt") + await fs.writeFile(outsideFile, "outside") + + // A directory link inside the workspace pointing at the outside dir: + // lexically `link/sneaky.txt` is inside the workspace. + await fs.symlink(outsideDir, path.join(service.workspaceDir, "link"), "dir") + + await expect(service.restoreFile(commit1!.commit, path.join("link", "sneaky.txt"))).rejects.toThrow( + /outside the workspace/, + ) + + // The outside file survives: the restore failed before any mutation. + expect(await fileExistsAtPath(outsideFile)).toBe(true) + expect(await fs.readFile(outsideFile, "utf-8")).toBe("outside") + }) + it("emits a restore event when a file is restored", async () => { const restoreListener = vi.fn() service.on("restore", restoreListener) From 4fc14c48d5dfb818a06e9ec0ae8a3b266c2c5feb Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 09:50:41 +0800 Subject: [PATCH 32/46] fix(fws): address CodeRabbit round-4 findings (unknown guard + pre-hydration defaults test) McpHub.spec: narrow the caught value with an unknown-safe type guard instead of an object cast when reading error.code (the 'in' check narrows to object & Record<'code', unknown>). ExtensionStateContext: export createInitialExtensionState and add a focused pre-hydration test asserting perWriteCheckpoints: true, changeCardDetail: 'summary', and writeDelayMs: DEFAULT_WRITE_DELAY_MS from the initializer itself, so a regression that drops the series defaults can no longer hide behind merge fixtures that supply the keys manually. --- src/services/mcp/__tests__/McpHub.spec.ts | 7 ++++++- .../src/context/ExtensionStateContext.tsx | 2 +- .../__tests__/ExtensionStateContext.spec.tsx | 20 ++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index 2a09fd84fa..116c87c1ec 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -56,7 +56,12 @@ vi.mock("../../../utils/safeWriteJson", () => ({ // Mirror the production safeWriteJson merge contract: only ENOENT // and SyntaxError are recoverable; an EACCES or I/O failure must // reject before the merge callback runs. - const code = (error as { code?: string })?.code + // unknown-safe narrowing: no cast on the caught value (the "in" + // check narrows to object & Record<"code", unknown>). + const code = + error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code + : undefined if (!(error instanceof SyntaxError) && code !== "ENOENT") { throw error } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index aa878316e0..64768e75e8 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -197,7 +197,7 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial } } -const createInitialExtensionState = (): ExtensionState => ({ +export const createInitialExtensionState = (): ExtensionState => ({ apiConfiguration: {}, version: "", clineMessages: [], diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index a031424a50..3f3367435b 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -11,9 +11,15 @@ import { type RouterModels, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, + DEFAULT_WRITE_DELAY_MS, } from "@roo-code/types" -import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext" +import { + ExtensionStateContextProvider, + useExtensionState, + mergeExtensionState, + createInitialExtensionState, +} from "../ExtensionStateContext" const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -212,6 +218,18 @@ describe("ExtensionStateContext", () => { expect(JSON.parse(screen.getByTestId("show-rooignored-files").textContent!)).toBe(true) }) + it("initializes the checkpoint keys and write delay to the pre-hydration defaults", () => { + // The initializer itself (not a merge fixture) must carry the series + // defaults: a regression that dropped them from createInitialExtensionState + // would otherwise stay hidden because the merge tests supply the keys + // manually. + const state = createInitialExtensionState() + + expect(state.perWriteCheckpoints).toBe(true) + expect(state.changeCardDetail).toBe("summary") + expect(state.writeDelayMs).toBe(DEFAULT_WRITE_DELAY_MS) + }) + it("initializes shadowed context fields from initialState", () => { const routerModels = {} as RouterModels const marketplaceItems: MarketplaceItem[] = [ From 0e021ef96a5a2f708976bb456af98fb80b5bee59 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 10:03:57 +0800 Subject: [PATCH 33/46] fix(fws): change-card step resolution, Tailwind v4 grow, sibling settings, Catalan fix ChangeCard resolves stepRollback from a correlated result that carries neither filePath nor files (the missing-task shape), instead of leaving the step pending; the three flex-grow utilities become the Tailwind v4 grow utility; checkpoints-changeCardDetail is a sibling SearchableSetting of checkpoints-perWriteCheckpoints instead of nested inside it; the Catalan rollbackFailed reads La reversio ha fallat. UI regressions added for the no-files failure/success shapes. (CodeRabbit findings on trial #1413). --- webview-ui/src/components/chat/ChangeCard.tsx | 11 +++-- .../chat/__tests__/ChangeCard.spec.tsx | 40 +++++++++++++++++++ .../settings/CheckpointSettings.tsx | 33 +++++++-------- webview-ui/src/i18n/locales/ca/chat.json | 2 +- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx index aa863e7a33..92b62d4a89 100644 --- a/webview-ui/src/components/chat/ChangeCard.tsx +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -90,6 +90,11 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { setStepRollback( result.success ? successState : { status: "error", error: firstFailure?.error ?? result.error }, ) + } else if (filePath === undefined) { + // Step-level result with no per-file payload (for example the + // missing-task response: success: false, no files). Without this the + // step button would stay in the in-progress state forever. + setStepRollback(result.success ? successState : { status: "error", error: result.error }) } } window.addEventListener("message", handler) @@ -274,13 +279,13 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { {t("chat:changeCard.header", { count: card.totalFiles })} - + {stepRollbackControls()}
{card.files.map((file, index) => (
-
+
{file.diff != null ? ( { {formatPathTooltip(file.path)} - + {diffBadges(file.additions, file.deletions)}
)} diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx index 78ba90a17c..4fe11a5dc5 100644 --- a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -251,6 +251,46 @@ describe("ChangeCard", () => { expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() }) + it("resolves the step state from a failure result that carries no files", async () => { + // The missing-task response is a step-level result with success: false + // and no per-file payload (no files, no filePath). Without handling the + // empty shape the step button would stay in the pending state forever. + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "Checkpoints are not enabled for this task", + }, + }) + + // The error detail rides in the tooltip content; the visible state is + // the rollback-failed label. The assertion that matters here is that the + // step left the pending state at all (previously it would stay pending). + expect(await screen.findByTestId("change-card-step-error")).toHaveTextContent("Rollback failed") + }) + + it("keeps the step in the pending state until a no-files success resolves it", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-step-rollback")) + fireEvent.click(screen.getByText("Confirm")) + expect(screen.getByTestId("change-card-step-pending")).toBeInTheDocument() + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, success: true }, + }) + + expect(await screen.findByTestId("change-card-step-success")).toBeInTheDocument() + }) + it("ignores rollback results for other change cards", () => { renderWithExtensionState() diff --git a/webview-ui/src/components/settings/CheckpointSettings.tsx b/webview-ui/src/components/settings/CheckpointSettings.tsx index 160883cd20..41475c152c 100644 --- a/webview-ui/src/components/settings/CheckpointSettings.tsx +++ b/webview-ui/src/components/settings/CheckpointSettings.tsx @@ -57,22 +57,23 @@ export const CheckpointSettings = ({
{t("settings:checkpoints.perWrite.description")}
- - { - setCachedStateField("changeCardDetail", e.target.checked ? "full" : "summary") - }} - data-testid="change-card-detail-checkbox"> - {t("settings:checkpoints.changeCardDetail.label")} - -
- {t("settings:checkpoints.changeCardDetail.description")} -
-
+ + + + { + setCachedStateField("changeCardDetail", e.target.checked ? "full" : "summary") + }} + data-testid="change-card-detail-checkbox"> + {t("settings:checkpoints.changeCardDetail.label")} + +
+ {t("settings:checkpoints.changeCardDetail.description")} +
Date: Fri, 28 Aug 2026 10:12:09 +0800 Subject: [PATCH 34/46] fix(fws): test the pre-hydration change-card detail default createInitialExtensionState is exported (shared with the sibling pre-hydration default tests) and a focused test asserts it initializes changeCardDetail to summary: a regression that dropped the key from the initializer would otherwise stay hidden because the merge tests supply the key manually (CodeRabbit finding on trial #1413). --- .../src/context/ExtensionStateContext.tsx | 2 +- .../__tests__/ExtensionStateContext.spec.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 92c3e5f553..64a6d63af2 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -196,7 +196,7 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial } } -const createInitialExtensionState = (): ExtensionState => ({ +export const createInitialExtensionState = (): ExtensionState => ({ apiConfiguration: {}, version: "", clineMessages: [], diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 38d3b00f0b..fd82b94f72 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -13,7 +13,12 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, } from "@roo-code/types" -import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext" +import { + ExtensionStateContextProvider, + useExtensionState, + mergeExtensionState, + createInitialExtensionState, +} from "../ExtensionStateContext" const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -333,6 +338,16 @@ describe("ExtensionStateContext", () => { } }) + it("initializes the change-card detail default before hydration", () => { + // The initializer itself (not a merge fixture) must carry the change-card + // detail default: a regression that dropped it from + // createInitialExtensionState would otherwise stay hidden because the + // merge tests supply the key manually. + const state = createInitialExtensionState() + + expect(state.changeCardDetail).toBe("summary") + }) + it("updates apiConfiguration through setApiConfiguration", () => { render( From 1f36d8076130a466b65b01070878fc22ce566822 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 15:40:41 +0800 Subject: [PATCH 35/46] feat(fws): record per-write checkpoint and change card for apply_diff (B3a, #1375) --- src/core/tools/ApplyDiffTool.ts | 40 +++- .../applyDiffTool.changeCard.spec.ts | 199 ++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 3b664b3bd2..037228cfd3 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -13,6 +13,8 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import type { ToolUse } from "../../shared/tools" +import { checkAutoApproval } from "../auto-approval" +import { checkpointSave } from "../checkpoints" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -144,9 +146,13 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diff: diffContent, } + // Hoisted: both save branches build the same approval message, and the + // B3a per-write checkpoint (below) needs it for auto-approval parity. + let completeMessage = "" + if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view - const completeMessage = JSON.stringify({ + completeMessage = JSON.stringify({ ...sharedMessageProps, diff: diffContent, content: unifiedPatch, @@ -191,7 +197,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { await task.diffViewProvider.update(diffResult.content, true) task.diffViewProvider.scrollToFirstDiff() - const completeMessage = JSON.stringify({ + completeMessage = JSON.stringify({ ...sharedMessageProps, diff: diffContent, content: unifiedPatch, @@ -224,6 +230,36 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) } + // B3a: per-write checkpoint + change card for the apply_diff write, with + // the same parity as write_to_file / edit_file / apply_patch (this tool + // previously wrote with no checkpoint and no card, so its edits had no + // per-file rollback surface in chat). The applied unified diff and its + // stats were already computed above for the tool message and are reused + // verbatim; auto-approved steps always get the compact card. Live setting + // with default-on semantics: skip only when explicitly false. apply_diff + // only ever modifies an existing file (a missing file errors out before + // the diff is applied), so the operation is always "update". + const perWriteCheckpoints = state?.perWriteCheckpoints + if (perWriteCheckpoints !== false) { + const autoApproved = + ( + await checkAutoApproval({ + state, + cwd: task.cwd, + ask: "tool", + text: completeMessage, + isProtected: isWriteProtected, + }) + ).decision === "approve" + await checkpointSave(task, false, true, { + path: relPath, + operation: "update", + ...(diffStats ? { diffStats: { additions: diffStats.added, deletions: diffStats.removed } } : {}), + ...(unifiedPatch ? { diff: unifiedPatch } : {}), + ...(autoApproved ? { autoApproved: true } : {}), + }).catch(() => {}) + } + // Track file edit operation if (relPath) { await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) diff --git a/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts b/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts new file mode 100644 index 0000000000..3262de28e2 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts @@ -0,0 +1,199 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.changeCard.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import { checkAutoApproval } from "../../auto-approval" +import { checkpointSave } from "../../checkpoints" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Error: ${msg}`), + rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../diff/stats", () => ({ + // The real DiffStats shape is { added, removed } (the tool maps it to the + // change-card { additions, deletions } pair). + sanitizeUnifiedDiff: vi.fn((diff: string) => diff), + computeDiffStats: vi.fn(() => ({ added: 1, removed: 1 })), +})) + +vi.mock("../../checkpoints", () => ({ + checkpointSave: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("../../auto-approval", () => ({ + checkAutoApproval: vi.fn().mockResolvedValue({ decision: "ask" }), +})) + +describe("ApplyDiffTool.execute - per-write checkpoint and change card (B3a, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + const mockCheckpointSave = checkpointSave as MockedFunction + const mockCheckAutoApproval = checkAutoApproval as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "processQueuedMessages" + | "didEditFile" + | "api" + | "diffStrategy" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockGetState: MockedFunction<() => Promise>> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + // Structural stubs for the prevent-focus-disruption save path: the real + // DiffViewProvider is out of scope here, so vi.fn() doubles stand in for + // the members the tool touches. + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + + mockGetState = vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }) + + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + processQueuedMessages: vi.fn(), + didEditFile: false, + api: { + getModel: () => ({ id: "claude-sonnet-4-5" }), + } as unknown as Task["api"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }), + } as unknown as Task["diffStrategy"], + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: mockGetState, + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("records a per-write checkpoint with the applied diff after a successful write", async () => { + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCheckpointSave).toHaveBeenCalledTimes(1) + expect(mockCheckpointSave).toHaveBeenCalledWith(mockTask, false, true, { + path: "src/thing.ts", + operation: "update", + diffStats: { additions: 1, deletions: 1 }, + diff: "mock-diff", + }) + }) + + it("marks auto-approved steps so the card renders compact", async () => { + mockCheckAutoApproval.mockResolvedValueOnce({ decision: "approve" }) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCheckpointSave).toHaveBeenCalledTimes(1) + expect(mockCheckpointSave.mock.calls[0]?.[3]).toEqual(expect.objectContaining({ autoApproved: true })) + }) + + it("skips the checkpoint when perWriteCheckpoints is explicitly disabled", async () => { + mockGetState.mockResolvedValueOnce({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: true }, + perWriteCheckpoints: false, + }) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCheckpointSave).not.toHaveBeenCalled() + // The write itself still happens (the setting gates the checkpoint only), + // and auto-approval is never consulted when there is no card to build. + expect(mockSaveDirectly).toHaveBeenCalled() + expect(mockCheckAutoApproval).not.toHaveBeenCalled() + }) + + it("records nothing when the approval is declined", async () => { + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockCheckpointSave).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + }) +}) From 7f6d1368ce1e7f64f9e3586e1adc331c70138aa3 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 15:43:42 +0800 Subject: [PATCH 36/46] feat(fws): add per-file open-in-editor control to change cards (B3b, #1375) --- webview-ui/src/components/chat/ChangeCard.tsx | 18 +++++++++ .../chat/__tests__/ChangeCard.spec.tsx | 40 +++++++++++++++++++ webview-ui/src/i18n/locales/ca/chat.json | 3 +- webview-ui/src/i18n/locales/de/chat.json | 3 +- webview-ui/src/i18n/locales/en/chat.json | 3 +- webview-ui/src/i18n/locales/es/chat.json | 3 +- webview-ui/src/i18n/locales/fr/chat.json | 3 +- webview-ui/src/i18n/locales/hi/chat.json | 3 +- webview-ui/src/i18n/locales/id/chat.json | 3 +- webview-ui/src/i18n/locales/it/chat.json | 3 +- webview-ui/src/i18n/locales/ja/chat.json | 3 +- webview-ui/src/i18n/locales/ko/chat.json | 3 +- webview-ui/src/i18n/locales/nl/chat.json | 3 +- webview-ui/src/i18n/locales/pl/chat.json | 3 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 3 +- webview-ui/src/i18n/locales/ru/chat.json | 3 +- webview-ui/src/i18n/locales/tr/chat.json | 3 +- webview-ui/src/i18n/locales/vi/chat.json | 3 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 3 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 3 +- 20 files changed, 94 insertions(+), 18 deletions(-) diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx index 92b62d4a89..b75c7f3ce8 100644 --- a/webview-ui/src/components/chat/ChangeCard.tsx +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -133,6 +133,13 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { setStepRollback({ status: "pending" }) } + // Open the changed file in the editor. The extension host resolves relative + // paths against the current cwd (webviewMessageHandler "openFile"), so + // normalize the "./" prefix the same way FileChangesPanel does. + const openFileInEditor = (path: string) => { + vscode.postMessage({ type: "openFile", text: path.startsWith("./") ? path : "./" + path }) + } + const diffBadges = (additions: number, deletions: number) => additions > 0 || deletions > 0 ? ( @@ -293,6 +300,7 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { language="diff" isExpanded={expandedFiles.has(file.path)} onToggleExpand={() => toggleFile(file.path)} + onJumpToFile={() => openFileInEditor(file.path)} diffStats={{ added: file.additions, removed: file.deletions }} /> ) : ( @@ -302,6 +310,16 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { {diffBadges(file.additions, file.deletions)} + openFileInEditor(file.path)} + />
)}
diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx index 4fe11a5dc5..313d760716 100644 --- a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -27,6 +27,7 @@ vi.mock("react-i18next", () => ({ "chat:changeCard.rolledBack": "Rolled back", "chat:changeCard.stepRolledBack": "Step rolled back", "chat:changeCard.rollbackFailed": "Rollback failed", + "chat:changeCard.openFile": "Open file", } return map[key] || key }, @@ -325,6 +326,45 @@ describe("ChangeCard", () => { expect(screen.getByTestId("change-card-step-rollback")).toBeInTheDocument() expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "checkpointRollbackStep" })) }) + + it("posts an openFile message from both the diff-row jump icon and the no-diff-row button", () => { + renderWithExtensionState( + , + ) + + // Diff row: the CodeAccordion header jump icon (own aria-label). + fireEvent.click(screen.getByLabelText("Open file: src/a.ts")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openFile", text: "./src/a.ts" }) + mockPostMessage.mockClear() + + // No-diff row: the open control on the plain path row. + fireEvent.click(screen.getByTestId("change-card-file-open-1")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openFile", text: "./src/b.ts" }) + }) + + it("does not double-prefix paths that already carry the ./ marker", () => { + renderWithExtensionState( + , + ) + + fireEvent.click(screen.getByTestId("change-card-file-open-0")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openFile", text: "./src/c.ts" }) + }) }) describe("ChatRow - change_card say", () => { diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 9438c99922..a8633f23bf 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Revertint...", "rolledBack": "Revertit", "stepRolledBack": "Pas revertit", - "rollbackFailed": "La reversió ha fallat" + "rollbackFailed": "La reversió ha fallat", + "openFile": "Obre el fitxer" }, "checkpoint": { "regular": "Punt de control", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index a6cbd56be6..bbc812eefe 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Wird zurückgesetzt...", "rolledBack": "Zurückgesetzt", "stepRolledBack": "Schritt zurückgesetzt", - "rollbackFailed": "Zurücksetzen fehlgeschlagen" + "rollbackFailed": "Zurücksetzen fehlgeschlagen", + "openFile": "Datei öffnen" }, "checkpoint": { "regular": "Checkpoint", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 4da79710d1..ce4f80cabd 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -199,7 +199,8 @@ "rollingBack": "Rolling back...", "rolledBack": "Rolled back", "stepRolledBack": "Step rolled back", - "rollbackFailed": "Rollback failed" + "rollbackFailed": "Rollback failed", + "openFile": "Open file" }, "checkpoint": { "regular": "Checkpoint", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 656bb48366..a4a1b6c4fd 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Revertiendo...", "rolledBack": "Revertido", "stepRolledBack": "Paso revertido", - "rollbackFailed": "La reversión falló" + "rollbackFailed": "La reversión falló", + "openFile": "Abrir archivo" }, "checkpoint": { "regular": "Punto de control", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 4bfbdefe43..573fcb7ad1 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Réinitialisation...", "rolledBack": "Réinitialisé", "stepRolledBack": "Étape réinitialisée", - "rollbackFailed": "Échec de la réinitialisation" + "rollbackFailed": "Échec de la réinitialisation", + "openFile": "Ouvrir le fichier" }, "checkpoint": { "regular": "Point de contrôle", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 83f72be63b..24de278da3 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -177,7 +177,8 @@ "rollingBack": "रोलबैक हो रहा है...", "rolledBack": "रोलबैक हो गया", "stepRolledBack": "चरण रोलबैक हो गया", - "rollbackFailed": "रोलबैक विफल" + "rollbackFailed": "रोलबैक विफल", + "openFile": "फाट़ल खोलें" }, "checkpoint": { "regular": "चेकपॉइंट", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 72679b15b8..8fa27e11eb 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -202,7 +202,8 @@ "rollingBack": "Mengembalikan...", "rolledBack": "Dikembalikan", "stepRolledBack": "Langkah dikembalikan", - "rollbackFailed": "Pembalikan gagal" + "rollbackFailed": "Pembalikan gagal", + "openFile": "Buka file" }, "checkpoint": { "regular": "Checkpoint", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 0ab7520f02..a3179ad6ee 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -180,7 +180,8 @@ "rollingBack": "Annullamento...", "rolledBack": "Annullato", "stepRolledBack": "Passaggio annullato", - "rollbackFailed": "Annullamento non riuscito" + "rollbackFailed": "Annullamento non riuscito", + "openFile": "Apri file" }, "checkpoint": { "regular": "Checkpoint", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 44f2020d5d..d3929f40e2 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -177,7 +177,8 @@ "rollingBack": "ロールバック中...", "rolledBack": "ロールバック済み", "stepRolledBack": "ステップをロールバックしました", - "rollbackFailed": "ロールバックに失敗しました" + "rollbackFailed": "ロールバックに失敗しました", + "openFile": "ファイルを開く" }, "checkpoint": { "regular": "チェックポイント", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index ec7e8339ac..c5868f8191 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -177,7 +177,8 @@ "rollingBack": "되돌리는 중...", "rolledBack": "되돌림", "stepRolledBack": "단계가 되돌려졌음", - "rollbackFailed": "되돌리기 실패" + "rollbackFailed": "되돌리기 실패", + "openFile": "파일 열기" }, "checkpoint": { "regular": "체크포인트", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 1517638dbc..8f01a641a8 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -172,7 +172,8 @@ "rollingBack": "Terugzetten...", "rolledBack": "Teruggezet", "stepRolledBack": "Stap teruggezet", - "rollbackFailed": "Terugzetten mislukt" + "rollbackFailed": "Terugzetten mislukt", + "openFile": "Bestand openen" }, "checkpoint": { "regular": "Checkpoint", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 07b569926f..622896b3f3 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Cofanie...", "rolledBack": "Cofnięto", "stepRolledBack": "Krok cofnięty", - "rollbackFailed": "Cofnięcie nie powiodło się" + "rollbackFailed": "Cofnięcie nie powiodło się", + "openFile": "Otwórz plik" }, "checkpoint": { "regular": "Punkt kontrolny", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index df4fb55998..1a4964593a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Revertendo...", "rolledBack": "Revertido", "stepRolledBack": "Etapa revertida", - "rollbackFailed": "Falha na reversão" + "rollbackFailed": "Falha na reversão", + "openFile": "Abrir arquivo" }, "checkpoint": { "regular": "Ponto de verificação", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 1db58e0537..cdbd185d9d 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -172,7 +172,8 @@ "rollingBack": "Выполняется откат...", "rolledBack": "Откат выполнен", "stepRolledBack": "Шаг откатен", - "rollbackFailed": "Ошибка отката" + "rollbackFailed": "Ошибка отката", + "openFile": "Открыть файл" }, "checkpoint": { "regular": "Точка сохранения", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 8b8a862807..caa674bae6 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Geri alınıyor...", "rolledBack": "Geri alındı", "stepRolledBack": "Adım geri alındı", - "rollbackFailed": "Geri alma başarısız" + "rollbackFailed": "Geri alma başarısız", + "openFile": "Dosyayı aç" }, "checkpoint": { "regular": "Kontrol Noktası", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 95b7b75b8e..5555b25d32 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -177,7 +177,8 @@ "rollingBack": "Đang hoàn tác...", "rolledBack": "Đã hoàn tác", "stepRolledBack": "Đã hoàn tác bước", - "rollbackFailed": "Hoàn tác thất bại" + "rollbackFailed": "Hoàn tác thất bại", + "openFile": "Mở tệp" }, "checkpoint": { "regular": "Điểm kiểm tra", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 573e1a9092..ab38f222d6 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -177,7 +177,8 @@ "rollingBack": "正在回退...", "rolledBack": "已回退", "stepRolledBack": "步骤已回退", - "rollbackFailed": "回退失败" + "rollbackFailed": "回退失败", + "openFile": "打开文件" }, "checkpoint": { "regular": "检查点", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index f5054e5ec5..91be91a485 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -199,7 +199,8 @@ "rollingBack": "正在還原...", "rolledBack": "已還原", "stepRolledBack": "步驟已還原", - "rollbackFailed": "還原失敗" + "rollbackFailed": "還原失敗", + "openFile": "開啟檔案" }, "checkpoint": { "regular": "檢查點", From 94fea2fe916b1bdb1e26a57ec7ee6ca5021da6c0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 15:44:44 +0800 Subject: [PATCH 37/46] test(fws): keep the apply_diff change-card spec green on the trial stat-pair read (S4b) --- src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts b/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts index 3262de28e2..de8914ce00 100644 --- a/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.changeCard.spec.ts @@ -11,6 +11,12 @@ import { ApplyDiffTool } from "../ApplyDiffTool" vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue("original file content\n"), + // The trial aggregate adds the S4b stat-pair self-observation around this + // read (ApplyDiffTool records its own read under the ReadFileTool contract); + // its `.catch(() => undefined)` fallback keeps the observation no-op when + // stat rejects, so this spec stays green on both the component branch and + // the trial aggregate. + stat: vi.fn().mockRejectedValue(new Error("stat unavailable in this spec")), }, })) From c48522cb888fcc74306ff73c154df1d9ad867508 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 28 Aug 2026 16:29:41 +0800 Subject: [PATCH 38/46] fix(fws): use a native button for the compact-row open-file control (CodeRabbit a11y, B3b, #1375) --- webview-ui/src/components/chat/ChangeCard.tsx | 19 ++++++++++------- .../chat/__tests__/ChangeCard.spec.tsx | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx index b75c7f3ce8..ee8953378b 100644 --- a/webview-ui/src/components/chat/ChangeCard.tsx +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -310,16 +310,21 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { {diffBadges(file.additions, file.deletions)} - openFileInEditor(file.path)} - /> + onClick={() => openFileInEditor(file.path)}> + +
)}
diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx index 313d760716..1f4eb499b9 100644 --- a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -365,6 +365,27 @@ describe("ChangeCard", () => { fireEvent.click(screen.getByTestId("change-card-file-open-0")) expect(mockPostMessage).toHaveBeenCalledWith({ type: "openFile", text: "./src/c.ts" }) }) + + it("renders the no-diff row open control as a native button so keyboard users can activate it", () => { + renderWithExtensionState( + , + ) + + // A native + + + ) + case "pending": + return ( + + + + ) + case "success": + return ( + + + {t("chat:changeCard.restored")} + + ) + case "error": + return ( + + + + {t("chat:changeCard.restoreFailed")} + + + ) + default: + return ( + + + + ) + } + } + const stepRollbackControls = () => { switch (stepRollback.status) { case "confirming": @@ -328,7 +420,10 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => {
)} -
{fileRollbackControls(file.path, index)}
+
+ {fileRollbackControls(file.path, index)} + {fileRestoreLatestControls(file.path, index)} +
))} diff --git a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx index 1f4eb499b9..33f546f56f 100644 --- a/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChangeCard.spec.tsx @@ -19,6 +19,11 @@ vi.mock("react-i18next", () => ({ const map: Record = { "chat:changeCard.header": `${options?.count ?? 0} file(s) changed this step`, "chat:changeCard.rollbackFile": "Rollback this file", + "chat:changeCard.rollbackFileWarning": "Restores this file to the content it had before this step.", + "chat:changeCard.restoreLatest": "Restore latest version", + "chat:changeCard.restoreLatestWarning": "Restores this file to the latest recorded version.", + "chat:changeCard.restored": "Restored latest version", + "chat:changeCard.restoreFailed": "Restore failed", "chat:changeCard.rollbackStep": "Rollback step", "chat:changeCard.rollbackWarning": "Restores the previous content of this step's files.", "chat:changeCard.confirm": "Confirm", @@ -386,6 +391,115 @@ describe("ChangeCard", () => { expect(control.tagName).toBe("BUTTON") expect(control).toHaveAttribute("aria-label", "Open file") }) + + it("restores one file to the latest version through checkpointRestoreLatestFile and shows pending + success", async () => { + renderWithExtensionState() + + // Open the confirm step for the restore-latest control. + fireEvent.click(screen.getByTestId("change-card-file-restore-0")) + expect(screen.getByTestId("change-card-file-restore-confirm-0")).toBeInTheDocument() + expect(screen.getByText("Restores this file to the latest recorded version.")).toBeInTheDocument() + + // Confirm sends the webview->extension message and goes pending. + fireEvent.click(screen.getByText("Confirm")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + expect(screen.getByTestId("change-card-file-restore-pending-0")).toBeInTheDocument() + + // The extension ack (kind "restore-latest") resolves the pending state. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: true, + }, + }) + expect(await screen.findByTestId("change-card-file-restore-success-0")).toHaveTextContent( + "Restored latest version", + ) + }) + + it("shows the restore-latest error state on a failed ack", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-file-restore-1")) + fireEvent.click(screen.getByText("Confirm")) + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/b.ts", + success: false, + error: "checkpoint not found", + }, + }) + + expect(await screen.findByTestId("change-card-file-restore-error-1")).toHaveTextContent("Restore failed") + }) + + it("treats a no-op restore-latest (no recorded write) as a success", async () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-file-restore-0")) + fireEvent.click(screen.getByText("Confirm")) + + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: true, + noOp: true, + }, + }) + + expect(await screen.findByTestId("change-card-file-restore-success-0")).toBeInTheDocument() + }) + + it("keeps the rollback and restore-latest controls independent on correlated results", async () => { + renderWithExtensionState() + + // A rollback result (no kind: the legacy shape) updates only the + // rollback control of the file. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { cardTs: 1000, filePath: "src/a.ts", success: true }, + }) + expect(await screen.findByTestId("change-card-file-success-0")).toBeInTheDocument() + expect(screen.getByTestId("change-card-file-restore-0")).toBeInTheDocument() + + // A restore-latest result updates only the restore control. + fireRollbackResult({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: true, + }, + }) + expect(await screen.findByTestId("change-card-file-restore-success-0")).toBeInTheDocument() + // The rollback control keeps its own success state (not overwritten). + expect(screen.getByTestId("change-card-file-success-0")).toBeInTheDocument() + }) + + it("cancels the file restore-latest confirmation without sending a message", () => { + renderWithExtensionState() + + fireEvent.click(screen.getByTestId("change-card-file-restore-0")) + fireEvent.click(screen.getByTestId("change-card-file-restore-cancel-0")) + expect(screen.getByTestId("change-card-file-restore-0")).toBeInTheDocument() + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "checkpointRestoreLatestFile" }), + ) + }) }) describe("ChatRow - change_card say", () => { diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index a8633f23bf..ab8f152567 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} fitxer(s) canviat(s) en aquest pas", "rollbackFile": "Revertir aquest fitxer", + "rollbackFileWarning": "Restaura aquest fitxer al contingut que tenia abans d'aquest pas.", + "restoreLatest": "Restaura l'última versió", + "restoreLatestWarning": "Restaura aquest fitxer a l'última versió registrada.", + "restored": "Última versió restaurada", + "restoreFailed": "Error en restaurar", "rollbackStep": "Revertir el pas", "rollbackWarning": "Restaura el contingut anterior dels fitxers d'aquest pas.", "confirm": "Confirmar", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index bbc812eefe..15ad354b28 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} Datei(en) in diesem Schritt geändert", "rollbackFile": "Diese Datei zurücksetzen", + "rollbackFileWarning": "Stellt diese Datei auf den Inhalt vor diesem Schritt wieder her.", + "restoreLatest": "Letzte Version wiederherstellen", + "restoreLatestWarning": "Stellt diese Datei auf die zuletzt aufgezeichnete Version wieder her.", + "restored": "Letzte Version wiederhergestellt", + "restoreFailed": "Wiederherstellung fehlgeschlagen", "rollbackStep": "Schritt zurücksetzen", "rollbackWarning": "Stellt den vorherigen Inhalt der Dateien dieses Schritts wieder her.", "confirm": "Bestätigen", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index ce4f80cabd..ec8ff3bf89 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -192,6 +192,11 @@ "changeCard": { "header": "{{count}} file(s) changed this step", "rollbackFile": "Rollback this file", + "rollbackFileWarning": "Restores this file to the content it had before this step.", + "restoreLatest": "Restore latest version", + "restoreLatestWarning": "Restores this file to the latest recorded version.", + "restored": "Restored latest version", + "restoreFailed": "Restore failed", "rollbackStep": "Rollback step", "rollbackWarning": "Restores the previous content of this step's files.", "confirm": "Confirm", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index a4a1b6c4fd..e4887e63a8 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} archivo(s) cambiado(s) en este paso", "rollbackFile": "Revertir este archivo", + "rollbackFileWarning": "Restaura este archivo al contenido que tenía antes de este paso.", + "restoreLatest": "Restaurar la última versión", + "restoreLatestWarning": "Restaura este archivo a la última versión registrada.", + "restored": "Última versión restaurada", + "restoreFailed": "Error al restaurar", "rollbackStep": "Revertir paso", "rollbackWarning": "Restaura el contenido anterior de los archivos de este paso.", "confirm": "Confirmar", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 573fcb7ad1..153cba524f 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} fichier(s) modifié(s) à cette étape", "rollbackFile": "Réinitialiser ce fichier", + "rollbackFileWarning": "Restaure ce fichier à son contenu avant cette étape.", + "restoreLatest": "Restaurer la dernière version", + "restoreLatestWarning": "Restaure ce fichier à la dernière version enregistrée.", + "restored": "Dernière version restaurée", + "restoreFailed": "Échec de la restauration", "rollbackStep": "Réinitialiser l'étape", "rollbackWarning": "Restaure le contenu précédent des fichiers de cette étape.", "confirm": "Confirmer", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 24de278da3..f54bcb4867 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "इस चरण में {{count}} फ़ाइल(ें) बदली गईं", "rollbackFile": "इस फ़ाइल को रोलबैक करें", + "rollbackFileWarning": "इस फ़ाइल को इस चरण से पहले की सामग्री पर पुनर्स्थापित करता है।", + "restoreLatest": "नवीनतम संस्करण पुनर्स्थापित करें", + "restoreLatestWarning": "इस फ़ाइल को नवीनतम दर्ज संस्करण पर पुनर्स्थापित करता है।", + "restored": "नवीनतम संस्करण पुनर्स्थापित हुआ", + "restoreFailed": "पुनर्स्थापना विफल", "rollbackStep": "चरण रोलबैक करें", "rollbackWarning": "यह चरण की फ़ाइलों की पिछली सामग्री पुनर्स्थापित करता है।", "confirm": "पुष्टि करें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 8fa27e11eb..79bc73f2f5 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -195,6 +195,11 @@ "changeCard": { "header": "{{count}} file diubah pada langkah ini", "rollbackFile": "Kembalikan file ini", + "rollbackFileWarning": "Memulihkan file ini ke konten yang ada sebelum langkah ini.", + "restoreLatest": "Pulihkan versi terbaru", + "restoreLatestWarning": "Memulihkan file ini ke versi terbaru yang tercatat.", + "restored": "Versi terbaru dipulihkan", + "restoreFailed": "Gagal memulihkan", "rollbackStep": "Kembalikan langkah", "rollbackWarning": "Memulihkan konten sebelumnya dari file pada langkah ini.", "confirm": "Konfirmasi", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index a3179ad6ee..ab85c8471b 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -173,6 +173,11 @@ "changeCard": { "header": "{{count}} file modificati in questo passaggio", "rollbackFile": "Annulla modifiche a questo file", + "rollbackFileWarning": "Ripristina questo file al contenuto che aveva prima di questo passaggio.", + "restoreLatest": "Ripristina l'ultima versione", + "restoreLatestWarning": "Ripristina questo file all'ultima versione registrata.", + "restored": "Ultima versione ripristinata", + "restoreFailed": "Ripristino non riuscito", "rollbackStep": "Annulla modifiche del passaggio", "rollbackWarning": "Ripristina il contenuto precedente dei file di questo passaggio.", "confirm": "Conferma", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index d3929f40e2..76628cfa95 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "このステップで {{count}} 件のファイルが変更されました", "rollbackFile": "このファイルをロールバック", + "rollbackFileWarning": "このファイルをこのステップ実行前の内容に復元します。", + "restoreLatest": "最新バージョンを復元", + "restoreLatestWarning": "このファイルを最新記録バージョンに復元します。", + "restored": "最新バージョンを復元しました", + "restoreFailed": "復元に失敗しました", "rollbackStep": "ステップをロールバック", "rollbackWarning": "このステップのファイルを元のコンテンツに復元します。", "confirm": "確認", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index c5868f8191..0196e849e8 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "이 단계에서 {{count}}개 파일이 변경됨", "rollbackFile": "이 파일 되돌리기", + "rollbackFileWarning": "이 파일을 이 단계 실행 전 내용으로 복원합니다.", + "restoreLatest": "최신 버전 복원", + "restoreLatestWarning": "이 파일을 최신 기록 버전으로 복원합니다.", + "restored": "최신 버전이 복원됨", + "restoreFailed": "복원 실패", "rollbackStep": "단계 되돌리기", "rollbackWarning": "이 단계 파일의 이전 콘텐츠로 복원합니다.", "confirm": "확인", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 8f01a641a8..0e05792781 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -165,6 +165,11 @@ "changeCard": { "header": "{{count}} bestand(en) gewijzigd in deze stap", "rollbackFile": "Dit bestand terugzetten", + "rollbackFileWarning": "Stelt dit bestand terug naar de inhoud vóór deze stap.", + "restoreLatest": "Laatste versie herstellen", + "restoreLatestWarning": "Stelt dit bestand terug naar de laatst geregistreerde versie.", + "restored": "Laatste versie hersteld", + "restoreFailed": "Herstellen mislukt", "rollbackStep": "Stap terugzetten", "rollbackWarning": "Stelt de eerdere inhoud van de bestanden van deze stap weer in.", "confirm": "Bevestigen", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 622896b3f3..835258aa21 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} plik(ów) zmienionych w tym kroku", "rollbackFile": "Cofnij ten plik", + "rollbackFileWarning": "Przywraca ten plik do zawartości sprzed tego kroku.", + "restoreLatest": "Przywróć najnowszą wersję", + "restoreLatestWarning": "Przywraca ten plik do najnowszej zarejestrowanej wersji.", + "restored": "Przywrócono najnowszą wersję", + "restoreFailed": "Przywracanie nie powiodło się", "rollbackStep": "Cofnij krok", "rollbackWarning": "Przywraca poprzednią zawartość plików tego kroku.", "confirm": "Potwierdź", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 1a4964593a..b5ffacc304 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} arquivo(s) alterado(s) nesta etapa", "rollbackFile": "Reverter este arquivo", + "rollbackFileWarning": "Restaura este arquivo para o conteúdo que tinha antes desta etapa.", + "restoreLatest": "Restaurar última versão", + "restoreLatestWarning": "Restaura este arquivo para a última versão registrada.", + "restored": "Última versão restaurada", + "restoreFailed": "Falha ao restaurar", "rollbackStep": "Reverter etapa", "rollbackWarning": "Restaura o conteúdo anterior dos arquivos desta etapa.", "confirm": "Confirmar", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index cdbd185d9d..00e4ad7ac5 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -165,6 +165,11 @@ "changeCard": { "header": "В этом шаге изменено файлов: {{count}}", "rollbackFile": "Откатить этот файл", + "rollbackFileWarning": "Восстанавливает файл к содержимому, которое было до этого шага.", + "restoreLatest": "Восстановить последнюю версию", + "restoreLatestWarning": "Восстанавливает файл к последней записанной версии.", + "restored": "Последняя версия восстановлена", + "restoreFailed": "Ошибка восстановления", "rollbackStep": "Откатить шаг", "rollbackWarning": "Восстанавливает предыдущее содержимое файлов этого шага.", "confirm": "Подтвердить", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index caa674bae6..f6d2215a34 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "Bu adımda {{count}} dosya değiştirildi", "rollbackFile": "Bu dosyayı geri al", + "rollbackFileWarning": "Bu dosyayı bu adım öncesi içeriğine geri yükler.", + "restoreLatest": "Son sürümü geri yükle", + "restoreLatestWarning": "Bu dosyayı son kaydedilmiş sürüme geri yükler.", + "restored": "Son sürüm geri yüklendi", + "restoreFailed": "Geri yükleme başarısız", "rollbackStep": "Adımı geri al", "rollbackWarning": "Bu adımın dosyalarının önceki içeriğini geri yükler.", "confirm": "Onayla", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5555b25d32..9a165369e4 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "{{count}} tệp đã thay đổi trong bước này", "rollbackFile": "Hoàn tác tệp này", + "rollbackFileWarning": "Khôi phục tệp này về nội dung trước bước này.", + "restoreLatest": "Khôi phục phiên bản mới nhất", + "restoreLatestWarning": "Khôi phục tệp này về phiên bản được ghi lại gần nhất.", + "restored": "Đã khôi phục phiên bản mới nhất", + "restoreFailed": "Khôi phục thất bại", "rollbackStep": "Hoàn tác bước", "rollbackWarning": "Khôi phục nội dung trước đó của các tệp trong bước này.", "confirm": "Xác nhận", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index ab38f222d6..9c81a1e391 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -170,6 +170,11 @@ "changeCard": { "header": "此步骤中更改了 {{count}} 个文件", "rollbackFile": "回退此文件", + "rollbackFileWarning": "将此文件还原到此步骤执行前的内容。", + "restoreLatest": "还原至最新版本", + "restoreLatestWarning": "将此文件还原至最近一次记录的版本。", + "restored": "已还原至最新版本", + "restoreFailed": "还原失败", "rollbackStep": "回退此步骤", "rollbackWarning": "将恢复此步骤文件的上一版本内容。", "confirm": "确认", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 91be91a485..c8363ad530 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -192,6 +192,11 @@ "changeCard": { "header": "此步驟中變更了 {{count}} 個檔案", "rollbackFile": "還原此檔案", + "rollbackFileWarning": "將此檔案還原到此步驟執行前的內容。", + "restoreLatest": "還原至最新版本", + "restoreLatestWarning": "將此檔案還原至最近一次記錄的版本。", + "restored": "已還原至最新版本", + "restoreFailed": "還原失敗", "rollbackStep": "還原此步驟", "rollbackWarning": "將還原此步驟檔案的上一版內容。", "confirm": "確認", From 779cb4b24ee8e4fc7f919d50e85f4064356c5c5b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 29 Aug 2026 19:15:32 +0800 Subject: [PATCH 44/46] ci: retry mocked E2E (flaky 30s timeout in subtask approvals test, no code change; B3c/B3b addendum 9, #1375) From 22a3f3357006c7b3c00c7b999c8b5f02df1bf2b2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 29 Aug 2026 22:25:51 +0800 Subject: [PATCH 45/46] =?UTF-8?q?merge:=20file-write-safety=20trial=20?= =?UTF-8?q?=E2=80=94=20addendum=2010=20(CodeRabbit=20review=20fixes=20from?= =?UTF-8?q?=20PR=20#1410=20+=20#1412:=20stale-card=20rollback=20rejection,?= =?UTF-8?q?=20unreadable-journal=20failure,=20correlated=20webview=20rollb?= =?UTF-8?q?ack=20results,=20change-card=20error=20a11y,=20openFile=20path?= =?UTF-8?q?=20labels,=20locale=20corrections;=20B3c/B3b,=20#1375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/changeJournal.spec.ts | 10 ++ .../checkpoints/__tests__/rollback.spec.ts | 122 +++++++++++++++++- src/core/checkpoints/changeJournal.ts | 15 ++- src/core/checkpoints/rollback.ts | 88 +++++++++++-- .../webviewMessageHandler.rollback.spec.ts | 79 +++++++++++- src/core/webview/webviewMessageHandler.ts | 119 +++++++++++------ src/i18n/locales/ca/common.json | 2 + src/i18n/locales/de/common.json | 2 + src/i18n/locales/en/common.json | 2 + src/i18n/locales/es/common.json | 2 + src/i18n/locales/fr/common.json | 2 + src/i18n/locales/hi/common.json | 2 + src/i18n/locales/id/common.json | 2 + src/i18n/locales/it/common.json | 2 + src/i18n/locales/ja/common.json | 2 + src/i18n/locales/ko/common.json | 2 + src/i18n/locales/nl/common.json | 2 + src/i18n/locales/pl/common.json | 2 + src/i18n/locales/pt-BR/common.json | 2 + src/i18n/locales/ru/common.json | 2 + src/i18n/locales/tr/common.json | 2 + src/i18n/locales/vi/common.json | 2 + src/i18n/locales/zh-CN/common.json | 2 + src/i18n/locales/zh-TW/common.json | 2 + .../__tests__/ShadowCheckpointService.spec.ts | 5 +- webview-ui/src/components/chat/ChangeCard.tsx | 17 ++- .../chat/__tests__/ChangeCard.spec.tsx | 50 ++++++- webview-ui/src/i18n/locales/ca/chat.json | 2 +- webview-ui/src/i18n/locales/de/chat.json | 2 +- webview-ui/src/i18n/locales/en/chat.json | 2 +- webview-ui/src/i18n/locales/es/chat.json | 4 +- webview-ui/src/i18n/locales/fr/chat.json | 2 +- webview-ui/src/i18n/locales/hi/chat.json | 4 +- webview-ui/src/i18n/locales/id/chat.json | 2 +- webview-ui/src/i18n/locales/it/chat.json | 10 +- webview-ui/src/i18n/locales/ja/chat.json | 2 +- webview-ui/src/i18n/locales/ko/chat.json | 6 +- webview-ui/src/i18n/locales/nl/chat.json | 2 +- webview-ui/src/i18n/locales/nl/settings.json | 2 +- webview-ui/src/i18n/locales/pl/chat.json | 2 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 2 +- webview-ui/src/i18n/locales/ru/chat.json | 2 +- webview-ui/src/i18n/locales/tr/chat.json | 2 +- webview-ui/src/i18n/locales/vi/chat.json | 2 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 2 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 2 +- 46 files changed, 496 insertions(+), 99 deletions(-) diff --git a/src/core/checkpoints/__tests__/changeJournal.spec.ts b/src/core/checkpoints/__tests__/changeJournal.spec.ts index 8b4c8314d5..20f53551aa 100644 --- a/src/core/checkpoints/__tests__/changeJournal.spec.ts +++ b/src/core/checkpoints/__tests__/changeJournal.spec.ts @@ -62,6 +62,16 @@ describe("changeJournal", () => { expect(await loadChanges(tmpRoot, taskId)).toEqual([]) }) + it("propagates non-ENOENT read failures instead of reporting an empty journal", async () => { + // A directory at the journal path makes readFile fail with EISDIR — + // a stand-in for any permission or I/O failure (EACCES etc.). Such a + // failure must not be swallowed into "no changes": it would let a + // rollback report a no-op success without reading the history. + await fs.mkdir(journalPath(tmpRoot, taskId), { recursive: true }) + + await expect(loadChanges(tmpRoot, taskId)).rejects.toMatchObject({ code: "EISDIR" }) + }) + it("parses all entries in order with a clean tail", async () => { await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" })) await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" })) diff --git a/src/core/checkpoints/__tests__/rollback.spec.ts b/src/core/checkpoints/__tests__/rollback.spec.ts index 044f93e9eb..0af6384113 100644 --- a/src/core/checkpoints/__tests__/rollback.spec.ts +++ b/src/core/checkpoints/__tests__/rollback.spec.ts @@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { Task } from "../../task/Task" import type { ChangeJournalEntry } from "../changeJournal" import { getCheckpointService } from "../index" -import { appendChange } from "../changeJournal" +import * as changeJournal from "../changeJournal" +import { appendChange, journalPath } from "../changeJournal" import { restoreLatestFile, rollbackFile, rollbackStep } from "../rollback" vi.mock("../index", () => ({ @@ -98,6 +99,48 @@ describe("rollbackFile (B3c: undo the step's write to the file)", () => { expect(service.restoreFile).toHaveBeenCalledWith("sha-1", "src/a.ts") }) + it("rejects rolling back a step that is not the file's latest change", async () => { + // The file was written again by a later step (sha-2): rolling back the + // older step (sha-1) would overwrite the newer state, so it is + // rejected instead of silently destroying it. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(makeTask(), "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "File was modified in a later step; roll back the latest change card first", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal location is unavailable (no global storage)", async () => { + // No context on the provider double → the journal cannot even be + // located: a clear failure, not a silent miss on the file lookup. + const task = { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(undefined) }, + } as unknown as Task + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackFile(task, "sha-1", "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Change journal is unavailable for this task", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + it("fails cleanly when the file is not part of the given step checkpoint", async () => { await seedJournal([{ path: "src/a.ts", operation: "create", checkpointId: "sha-1" }]) const service = serviceWith("base-0") @@ -231,6 +274,28 @@ describe("rollbackStep (B3c: undo every file of the step)", () => { expect(service.restoreFile).toHaveBeenCalledTimes(1) }) + it("rejects the stale file of a step while restoring the others", async () => { + // src/a.ts was written again after this step's checkpoint, so its + // sha-2 entry is no longer the file's latest: only src/b.ts (whose + // latest entry IS sha-2) is restored. + await seedJournal([ + { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-2" }, + { path: "src/b.ts", operation: "create", checkpointId: "sha-2" }, + { path: "src/a.ts", operation: "update", checkpointId: "sha-3" }, + ]) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await rollbackStep(makeTask(), ["src/a.ts", "src/b.ts"], "sha-2") + + expect(outcome.files[0].success).toBe(false) + expect(outcome.files[0].error).toBe("File was modified in a later step; roll back the latest change card first") + expect(outcome.files[1]).toEqual({ filePath: "src/b.ts", success: true }) + expect(service.restoreFile).toHaveBeenCalledTimes(1) + expect(service.restoreFile).toHaveBeenCalledWith("base-0", "src/b.ts") + }) + it("falls back to the latest journal entry per file without a step checkpoint id", async () => { await seedJournal([ { path: "src/a.ts", operation: "create", checkpointId: "sha-1" }, @@ -258,8 +323,10 @@ describe("rollbackStep (B3c: undo every file of the step)", () => { expect(outcome.files[1].error).toBe("No change journal entry for this file") }) - it("treats a missing global storage directory as an empty journal", async () => { - // No context on the provider double → no journal location to read. + it("fails per file when the journal location is unavailable (no global storage)", async () => { + // No context on the provider double → the journal cannot even be + // located. That is a failure, not an empty journal: reporting the + // step as merely "not part of this checkpoint" would be misleading. const task = { taskId: "task-rollback", providerRef: { deref: vi.fn().mockReturnValue(undefined) }, @@ -272,7 +339,7 @@ describe("rollbackStep (B3c: undo every file of the step)", () => { expect(outcome.checkpointId).toBe("sha-2") expect(outcome.files[0].success).toBe(false) - expect(outcome.files[0].error).toBe("File is not part of this step's checkpoint") + expect(outcome.files[0].error).toBe("Change journal is unavailable for this task") expect(service.restoreFile).not.toHaveBeenCalled() }) @@ -315,6 +382,53 @@ describe("restoreLatestFile (B3c: forward direction)", () => { expect(service.restoreFile).not.toHaveBeenCalled() }) + it("fails when the journal location is unavailable (no global storage)", async () => { + // An unavailable journal is not "the task wrote nothing": a no-op + // success would claim a restore that never happened. + const task = { + taskId: "task-rollback", + providerRef: { deref: vi.fn().mockReturnValue(undefined) }, + } as unknown as Task + + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(task, "src/a.ts") + + expect(outcome).toEqual({ + filePath: "src/a.ts", + success: false, + error: "Change journal is unavailable for this task", + }) + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("fails when the journal cannot be read (an I/O error is not an empty journal)", async () => { + // A directory at the journal path makes readFile fail with EISDIR — + // a stand-in for any permission or I/O failure (EACCES etc.). + await fs.mkdir(journalPath(globalStorageDir, "task-rollback"), { recursive: true }) + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toContain("Change journal could not be read") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + + it("stringifies a non-Error journal read failure into the outcome", async () => { + vi.spyOn(changeJournal, "loadChanges").mockRejectedValueOnce("raw journal failure") + const service = serviceWith("base-0") + mockedGetCheckpointService.mockResolvedValue(service) + + const outcome = await restoreLatestFile(makeTask(), "src/a.ts") + + expect(outcome.success).toBe(false) + expect(outcome.error).toBe("Change journal could not be read: raw journal failure") + expect(service.restoreFile).not.toHaveBeenCalled() + }) + it("fails cleanly when checkpoints are not enabled", async () => { mockedGetCheckpointService.mockResolvedValue(undefined) diff --git a/src/core/checkpoints/changeJournal.ts b/src/core/checkpoints/changeJournal.ts index 44ac5ee33b..850d2b7f1d 100644 --- a/src/core/checkpoints/changeJournal.ts +++ b/src/core/checkpoints/changeJournal.ts @@ -52,7 +52,9 @@ export async function appendChange(globalStorageDir: string, taskId: string, ent * * Torn-tail repair: if the final line is truncated (JSON.parse fails), it is * silently discarded. The rest of the file is returned in order. An absent - * or empty journal returns []. + * or empty journal returns [] — but only an ABSENT file. Any other read + * failure (permissions, I/O) is rethrown: a journal that cannot be read must + * not be indistinguishable from one that is legitimately empty. */ export async function loadChanges(globalStorageDir: string, taskId: string): Promise { const filePath = journalPath(globalStorageDir, taskId) @@ -60,9 +62,14 @@ export async function loadChanges(globalStorageDir: string, taskId: string): Pro let content: string try { content = await fs.readFile(filePath, "utf8") - } catch { - // File absent or unreadable → empty journal. - return [] + } catch (error) { + // A missing journal is a legitimate empty history; any other read + // failure (permissions, I/O) must propagate. Swallowing it would let + // a rollback report a no-op success without reading the history. + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return [] + } + throw error } if (!content.trim()) { diff --git a/src/core/checkpoints/rollback.ts b/src/core/checkpoints/rollback.ts index 3595e0385d..508789c957 100644 --- a/src/core/checkpoints/rollback.ts +++ b/src/core/checkpoints/rollback.ts @@ -16,6 +16,12 @@ * the content of its most recent recorded write (a successful no-op when * the task never wrote the file). * + * A file is only rolled back from the card of its most recent step: undoing + * an older step for a file that a later step wrote again would overwrite the + * newer state, so such a rollback is rejected (a full checkpoint restore + * still reaches any older state). A journal that cannot be located or read + * fails the restore instead of masquerading as "the task wrote nothing". + * * Restores reuse the existing shadow-git service (`getCheckpointService` → * `RepoPerTaskCheckpointService.restoreFile`, the same instance whose * `restoreCheckpoint` the checkpoints UI uses) — nothing is forked. Only the @@ -45,17 +51,45 @@ const NOT_ENABLED_ERROR = "Checkpoints are not enabled for this task" const NO_TARGET_ERROR = "No checkpoint available to restore" const NOT_IN_STEP_ERROR = "File is not part of this step's checkpoint" const NO_ENTRY_ERROR = "No change journal entry for this file" +const NO_JOURNAL_ERROR = "Change journal is unavailable for this task" +const NOT_LATEST_ERROR = "File was modified in a later step; roll back the latest change card first" type CheckpointService = NonNullable>> -/** - * Load the task's change journal. `loadChanges` never throws: an absent or - * unreadable file and a torn tail resolve to the readable prefix (or `[]`). - */ -async function loadTaskEntries(task: Task): Promise { +/** A readable journal (possibly legitimately empty) or the reason it could not be loaded. */ +type LoadedJournal = { entries: ChangeJournalEntry[] } | { error: string } + +// `undefined` = the journal cannot be located (provider reference gone); read failures (permissions, I/O) propagate. +async function loadTaskEntries(task: Task): Promise { const globalStorageDir = task.providerRef.deref()?.context.globalStorageUri.fsPath - return globalStorageDir ? loadChanges(globalStorageDir, task.taskId) : [] + return globalStorageDir ? loadChanges(globalStorageDir, task.taskId) : undefined +} + +// Single discriminated result for callers: a readable journal (possibly empty) or a failure. +async function loadTaskJournal(task: Task): Promise { + try { + const entries = await loadTaskEntries(task) + + if (entries === undefined) { + return { error: NO_JOURNAL_ERROR } + } + + return { entries } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Change journal could not be read: ${message}` } + } +} + +/** The most recent journal entry recorded for `filePath`, if any. */ +function latestEntry(entries: ChangeJournalEntry[], filePath: string): ChangeJournalEntry | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].path === filePath) { + return entries[i] + } + } + return undefined } /** @@ -74,6 +108,16 @@ function preStepRestoreTarget( if (stepIndex === -1) { return { error: NOT_IN_STEP_ERROR } } + + // Only the file's most recent step may be rolled back: restoring an older + // state would overwrite the file's newer writes. A multi-write step shares + // one checkpoint id, so compare on the latest entry's checkpoint id. + const latest = fileEntries[fileEntries.length - 1] + + if (latest.checkpointId !== stepCheckpointId) { + return { error: NOT_LATEST_ERROR } + } + if (stepIndex === 0) { return { baseline: true } } @@ -114,8 +158,13 @@ export async function rollbackFile( return { filePath, success: false, error: NOT_ENABLED_ERROR } } - const entries = await loadTaskEntries(task) - const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) + const journal = await loadTaskJournal(task) + + if ("error" in journal) { + return { filePath, success: false, error: journal.error } + } + + const resolved = preStepRestoreTarget(journal.entries, filePath, stepCheckpointId) if (resolved.error) { return { filePath, success: false, error: resolved.error } @@ -153,9 +202,19 @@ export async function rollbackStep( } } - const entries = await loadTaskEntries(task) + const journal = await loadTaskJournal(task) const files: RollbackFileOutcome[] = [] + if ("error" in journal) { + const journalError = journal.error + return { + checkpointId: stepCheckpointId, + files: stepFiles.map((filePath) => ({ filePath, success: false, error: journalError })), + } + } + + const entries = journal.entries + for (const filePath of stepFiles) { if (stepCheckpointId) { const resolved = preStepRestoreTarget(entries, filePath, stepCheckpointId) @@ -176,7 +235,7 @@ export async function rollbackStep( continue } - const latest = [...entries].reverse().find((entry) => entry.path === filePath) + const latest = latestEntry(entries, filePath) if (!latest) { files.push({ filePath, success: false, error: NO_ENTRY_ERROR }) @@ -202,8 +261,13 @@ export async function restoreLatestFile(task: Task, filePath: string): Promise entry.path === filePath) + const journal = await loadTaskJournal(task) + + if ("error" in journal) { + return { filePath, success: false, error: journal.error } + } + + const latest = latestEntry(journal.entries, filePath) if (!latest) { return { filePath, success: true, noOp: true } diff --git a/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts index f75e3c4822..238e1daad5 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.rollback.spec.ts @@ -26,6 +26,20 @@ vi.mock("vscode", () => ({ }, })) +// The no-task failure posts localized copy; the extension i18n loader only +// populates resources outside tests, so the spec pins the English values the +// handler asks for (path is relative to this file: ../../../i18n = src/i18n). +vi.mock("../../../i18n", () => ({ + changeLanguage: vi.fn(), + t: (key: string) => { + const values: Record = { + "common:errors.message.no_active_task_to_roll_back": "No active task to roll back from", + "common:errors.message.no_active_task_to_restore": "No active task to restore from", + } + return values[key] ?? key + }, +})) + // Structural mock: the handler only needs the task identity for these cases. const mockTask = {} as Task const postMessageToWebview = vi.fn(async (_message: ExtensionMessage) => undefined) @@ -103,7 +117,28 @@ describe("webviewMessageHandler - change card rollback", () => { cardTs: 1000, filePath: "src/a.ts", success: false, - error: "No active task to roll back from.", + error: "No active task to roll back from", + }, + }) + }) + + it("posts a correlated failure when the rollback itself throws", async () => { + vi.mocked(rollbackFile).mockRejectedValueOnce(new Error("git restore failed")) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackFile", + payload: { cardTs: 1000, checkpointId: "abc123", filePath: "src/a.ts" }, + }) + + // The card must not stay pending: the handler turns the throw into a + // correlated failure result instead of dropping the message. + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + filePath: "src/a.ts", + success: false, + error: "Rollback failed: git restore failed", }, }) }) @@ -182,7 +217,25 @@ describe("webviewMessageHandler - change card rollback", () => { checkpointRollbackResult: { cardTs: 1000, success: false, - error: "No active task to roll back from.", + error: "No active task to roll back from", + }, + }) + }) + + it("posts a correlated failure when the step rollback itself throws", async () => { + vi.mocked(rollbackStep).mockRejectedValueOnce(new Error("journal unreadable")) + + await webviewMessageHandler(provider, { + type: "checkpointRollbackStep", + payload: { cardTs: 1000, filePaths: ["src/a.ts"] }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + success: false, + error: "Rollback failed: journal unreadable", }, }) }) @@ -293,7 +346,27 @@ describe("webviewMessageHandler - change card rollback", () => { kind: "restore-latest", filePath: "src/a.ts", success: false, - error: "No active task to restore from.", + error: "No active task to restore from", + }, + }) + }) + + it("posts a correlated failure when the restore itself throws", async () => { + vi.mocked(restoreLatestFile).mockRejectedValueOnce(new Error("git checkout failed")) + + await webviewMessageHandler(provider, { + type: "checkpointRestoreLatestFile", + payload: { cardTs: 1000, filePath: "src/a.ts" }, + }) + + expect(postMessageToWebview).toHaveBeenCalledWith({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: 1000, + kind: "restore-latest", + filePath: "src/a.ts", + success: false, + error: "Restore failed: git checkout failed", }, }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5ae28bc44a..2ba76f70c7 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1614,17 +1614,32 @@ export const webviewMessageHandler = async ( // editor integrations (DiffViewProvider) into the import graph. Loading it // only when a rollback is requested keeps specs that mock `vscode` minimally // from executing editor module-scope code at import time. - const { rollbackFile } = await import("../checkpoints/rollback") - const outcome = await rollbackFile(task, result.data.checkpointId, result.data.filePath) - await provider.postMessageToWebview({ - type: "checkpointRollbackResult", - checkpointRollbackResult: { - cardTs: result.data.cardTs, - filePath: outcome.filePath, - success: outcome.success, - ...(outcome.error ? { error: outcome.error } : {}), - }, - }) + try { + const { rollbackFile } = await import("../checkpoints/rollback") + const outcome = await rollbackFile(task, result.data.checkpointId, result.data.filePath) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: outcome.filePath, + success: outcome.success, + ...(outcome.error ? { error: outcome.error } : {}), + }, + }) + } catch (error) { + // Correlated failure: a throw between the request and the result post + // (import, journal read, git restore) would otherwise leave the + // requesting card pending forever. + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + filePath: result.data.filePath, + success: false, + error: `Rollback failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }) + } } else { // No active task: the rollback cannot run. Post the correlated // failure so the requesting card can clear its pending state @@ -1635,7 +1650,7 @@ export const webviewMessageHandler = async ( cardTs: result.data.cardTs, filePath: result.data.filePath, success: false, - error: "No active task to roll back from.", + error: t("common:errors.message.no_active_task_to_roll_back"), }, }) } @@ -1652,18 +1667,30 @@ export const webviewMessageHandler = async ( if (task) { // Lazy import (see the checkpointRollbackFile case above). - const { rollbackStep } = await import("../checkpoints/rollback") - const outcome = await rollbackStep(task, result.data.filePaths, result.data.checkpointId) - const firstFailure = outcome.files.find((file) => !file.success) - await provider.postMessageToWebview({ - type: "checkpointRollbackResult", - checkpointRollbackResult: { - cardTs: result.data.cardTs, - success: outcome.files.every((file) => file.success), - ...(firstFailure ? { error: firstFailure.error } : {}), - files: outcome.files, - }, - }) + try { + const { rollbackStep } = await import("../checkpoints/rollback") + const outcome = await rollbackStep(task, result.data.filePaths, result.data.checkpointId) + const firstFailure = outcome.files.find((file) => !file.success) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: outcome.files.every((file) => file.success), + ...(firstFailure ? { error: firstFailure.error } : {}), + files: outcome.files, + }, + }) + } catch (error) { + // Correlated failure (see the checkpointRollbackFile case). + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + success: false, + error: `Rollback failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }) + } } else { // No active task: post the correlated failure so the requesting // card can clear its pending state. @@ -1672,7 +1699,7 @@ export const webviewMessageHandler = async ( checkpointRollbackResult: { cardTs: result.data.cardTs, success: false, - error: "No active task to roll back from.", + error: t("common:errors.message.no_active_task_to_roll_back"), }, }) } @@ -1692,19 +1719,33 @@ export const webviewMessageHandler = async ( if (task) { // Lazy import (see the checkpointRollbackFile case above). - const { restoreLatestFile } = await import("../checkpoints/rollback") - const outcome = await restoreLatestFile(task, result.data.filePath) - await provider.postMessageToWebview({ - type: "checkpointRollbackResult", - checkpointRollbackResult: { - cardTs: result.data.cardTs, - kind: "restore-latest", - filePath: outcome.filePath, - success: outcome.success, - ...(outcome.noOp ? { noOp: true } : {}), - ...(outcome.error ? { error: outcome.error } : {}), - }, - }) + try { + const { restoreLatestFile } = await import("../checkpoints/rollback") + const outcome = await restoreLatestFile(task, result.data.filePath) + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + kind: "restore-latest", + filePath: outcome.filePath, + success: outcome.success, + ...(outcome.noOp ? { noOp: true } : {}), + ...(outcome.error ? { error: outcome.error } : {}), + }, + }) + } catch (error) { + // Correlated failure (see the checkpointRollbackFile case). + await provider.postMessageToWebview({ + type: "checkpointRollbackResult", + checkpointRollbackResult: { + cardTs: result.data.cardTs, + kind: "restore-latest", + filePath: result.data.filePath, + success: false, + error: `Restore failed: ${error instanceof Error ? error.message : String(error)}`, + }, + }) + } } else { // No active task: post the correlated failure so the requesting // card can clear its pending state. @@ -1715,7 +1756,7 @@ export const webviewMessageHandler = async ( kind: "restore-latest", filePath: result.data.filePath, success: false, - error: "No active task to restore from.", + error: t("common:errors.message.no_active_task_to_restore"), }, }) } diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 24ae3f310c..9500c6e35b 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -102,6 +102,8 @@ }, "message": { "no_active_task_to_delete": "No hi ha cap tasca activa de la qual eliminar missatges", + "no_active_task_to_roll_back": "No hi ha cap tasca activa per desfer", + "no_active_task_to_restore": "No hi ha cap tasca activa per restaurar", "invalid_timestamp_for_deletion": "Marca de temps del missatge no vàlida per a l'eliminació", "cannot_delete_missing_timestamp": "No es pot eliminar el missatge: falta la marca de temps", "cannot_delete_invalid_timestamp": "No es pot eliminar el missatge: marca de temps no vàlida", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 54fa0b3c22..b1affc0257 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Keine aktive Aufgabe, aus der Nachrichten gelöscht werden können", + "no_active_task_to_roll_back": "Keine aktive Aufgabe, von der aus zurückgerollt werden kann", + "no_active_task_to_restore": "Keine aktive Aufgabe, von der aus wiederhergestellt werden kann", "invalid_timestamp_for_deletion": "Ungültiger Nachrichten-Zeitstempel zum Löschen", "cannot_delete_missing_timestamp": "Nachricht kann nicht gelöscht werden: fehlender Zeitstempel", "cannot_delete_invalid_timestamp": "Nachricht kann nicht gelöscht werden: ungültiger Zeitstempel", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 516a3d4f88..8fd0c2049e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -100,6 +100,8 @@ }, "message": { "no_active_task_to_delete": "No active task to delete messages from", + "no_active_task_to_roll_back": "No active task to roll back from", + "no_active_task_to_restore": "No active task to restore from", "invalid_timestamp_for_deletion": "Invalid message timestamp for deletion", "cannot_delete_missing_timestamp": "Cannot delete message: missing timestamp", "cannot_delete_invalid_timestamp": "Cannot delete message: invalid timestamp", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 71dc994516..9dcf4cd27b 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "No hay tarea activa de la cual eliminar mensajes", + "no_active_task_to_roll_back": "No hay ninguna tarea activa desde la que deshacer los cambios", + "no_active_task_to_restore": "No hay ninguna tarea activa desde la que restaurar los cambios", "invalid_timestamp_for_deletion": "Marca de tiempo del mensaje no válida para eliminación", "cannot_delete_missing_timestamp": "No se puede eliminar el mensaje: falta marca de tiempo", "cannot_delete_invalid_timestamp": "No se puede eliminar el mensaje: marca de tiempo no válida", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 87009ee988..e6cc7e3cb7 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Aucune tâche active pour supprimer des messages", + "no_active_task_to_roll_back": "Aucune tâche active pour annuler les modifications", + "no_active_task_to_restore": "Aucune tâche active pour restaurer les modifications", "invalid_timestamp_for_deletion": "Horodatage du message invalide pour la suppression", "cannot_delete_missing_timestamp": "Impossible de supprimer le message : horodatage manquant", "cannot_delete_invalid_timestamp": "Impossible de supprimer le message : horodatage invalide", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f4bd1c3055..4e4933134f 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "संदेशों को हटाने के लिए कोई सक्रिय कार्य नहीं", + "no_active_task_to_roll_back": "वापस करने के लिए कोई सक्रिय कार्य नहीं है", + "no_active_task_to_restore": "पुनर्स्थापित करने के लिए कोई सक्रिय कार्य नहीं है", "invalid_timestamp_for_deletion": "हटाने के लिए अमान्य संदेश टाइमस्टैम्प", "cannot_delete_missing_timestamp": "संदेश हटाया नहीं जा सकता: टाइमस्टैम्प गुम है", "cannot_delete_invalid_timestamp": "संदेश हटाया नहीं जा सकता: अमान्य टाइमस्टैम्प", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index bcee321af5..47e37f4323 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Tidak ada tugas aktif untuk menghapus pesan", + "no_active_task_to_roll_back": "Tidak ada tugas aktif untuk di-rollback", + "no_active_task_to_restore": "Tidak ada tugas aktif untuk dipulihkan", "invalid_timestamp_for_deletion": "Timestamp pesan tidak valid untuk penghapusan", "cannot_delete_missing_timestamp": "Tidak dapat menghapus pesan: timestamp tidak ada", "cannot_delete_invalid_timestamp": "Tidak dapat menghapus pesan: timestamp tidak valid", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 395be16b84..5c3920390b 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Nessuna attività attiva da cui eliminare messaggi", + "no_active_task_to_roll_back": "Nessun task attivo da cui annullare le modifiche", + "no_active_task_to_restore": "Nessun task attivo da cui ripristinare le modifiche", "invalid_timestamp_for_deletion": "Timestamp del messaggio non valido per l'eliminazione", "cannot_delete_missing_timestamp": "Impossibile eliminare il messaggio: timestamp mancante", "cannot_delete_invalid_timestamp": "Impossibile eliminare il messaggio: timestamp non valido", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7dccfcd837..2fb3351331 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "メッセージを削除するアクティブなタスクがありません", + "no_active_task_to_roll_back": "ロールバックできるアクティブなタスクがありません", + "no_active_task_to_restore": "復元できるアクティブなタスクがありません", "invalid_timestamp_for_deletion": "削除用のメッセージタイムスタンプが無効です", "cannot_delete_missing_timestamp": "メッセージを削除できません:タイムスタンプがありません", "cannot_delete_invalid_timestamp": "メッセージを削除できません:タイムスタンプが無効です", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0ca65be687..23eb829e21 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "메시지를 삭제할 활성 작업이 없습니다", + "no_active_task_to_roll_back": "되돌릴 활성 작업이 없습니다", + "no_active_task_to_restore": "복원할 활성 작업이 없습니다", "invalid_timestamp_for_deletion": "삭제를 위한 메시지 타임스탬프가 유효하지 않습니다", "cannot_delete_missing_timestamp": "메시지를 삭제할 수 없습니다: 타임스탬프가 없습니다", "cannot_delete_invalid_timestamp": "메시지를 삭제할 수 없습니다: 타임스탬프가 유효하지 않습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index a38415edfd..21cafafad4 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Geen actieve taak om berichten uit te verwijderen", + "no_active_task_to_roll_back": "Geen actieve taak om terug te draaien", + "no_active_task_to_restore": "Geen actieve taak om te herstellen", "invalid_timestamp_for_deletion": "Ongeldig bericht tijdstempel voor verwijdering", "cannot_delete_missing_timestamp": "Kan bericht niet verwijderen: tijdstempel ontbreekt", "cannot_delete_invalid_timestamp": "Kan bericht niet verwijderen: ongeldig tijdstempel", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index ff898e8987..41ef2d7a70 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Brak aktywnego zadania do usunięcia wiadomości", + "no_active_task_to_roll_back": "Brak aktywnego zadania do cofnięcia", + "no_active_task_to_restore": "Brak aktywnego zadania do przywrócenia", "invalid_timestamp_for_deletion": "Nieprawidłowy znacznik czasu wiadomości do usunięcia", "cannot_delete_missing_timestamp": "Nie można usunąć wiadomości: brak znacznika czasu", "cannot_delete_invalid_timestamp": "Nie można usunąć wiadomości: nieprawidłowy znacznik czasu", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index d3c31ed2dd..d76488a7e5 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -103,6 +103,8 @@ }, "message": { "no_active_task_to_delete": "Nenhuma tarefa ativa para excluir mensagens", + "no_active_task_to_roll_back": "Nenhuma tarefa ativa para desfazer", + "no_active_task_to_restore": "Nenhuma tarefa ativa para restaurar", "invalid_timestamp_for_deletion": "Timestamp da mensagem inválido para exclusão", "cannot_delete_missing_timestamp": "Não é possível excluir mensagem: timestamp ausente", "cannot_delete_invalid_timestamp": "Não é possível excluir mensagem: timestamp inválido", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 08d2e2aa2c..57b06a5325 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Нет активной задачи для удаления сообщений", + "no_active_task_to_roll_back": "Нет активного задания для отката", + "no_active_task_to_restore": "Нет активного задания для восстановления", "invalid_timestamp_for_deletion": "Недействительная временная метка сообщения для удаления", "cannot_delete_missing_timestamp": "Невозможно удалить сообщение: отсутствует временная метка", "cannot_delete_invalid_timestamp": "Невозможно удалить сообщение: недействительная временная метка", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 716ccbc6de..3b49e740fd 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Mesaj silinecek aktif görev yok", + "no_active_task_to_roll_back": "Geri alınacak etkin görev yok", + "no_active_task_to_restore": "Geri yüklenecek etkin görev yok", "invalid_timestamp_for_deletion": "Silme için geçersiz mesaj zaman damgası", "cannot_delete_missing_timestamp": "Mesaj silinemiyor: zaman damgası eksik", "cannot_delete_invalid_timestamp": "Mesaj silinemiyor: geçersiz zaman damgası", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 69c6343c31..3f5a5abb58 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -99,6 +99,8 @@ }, "message": { "no_active_task_to_delete": "Không có nhiệm vụ hoạt động để xóa tin nhắn", + "no_active_task_to_roll_back": "Không có tác vụ hoạt động để hoàn tác", + "no_active_task_to_restore": "Không có tác vụ hoạt động để khôi phục", "invalid_timestamp_for_deletion": "Dấu thời gian tin nhắn không hợp lệ để xóa", "cannot_delete_missing_timestamp": "Không thể xóa tin nhắn: thiếu dấu thời gian", "cannot_delete_invalid_timestamp": "Không thể xóa tin nhắn: dấu thời gian không hợp lệ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 3600f0aa7c..0cac675500 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -104,6 +104,8 @@ }, "message": { "no_active_task_to_delete": "没有可删除消息的活跃任务", + "no_active_task_to_roll_back": "没有可回滚的活动任务", + "no_active_task_to_restore": "没有可恢复的活动任务", "invalid_timestamp_for_deletion": "删除操作的消息时间戳无效", "cannot_delete_missing_timestamp": "无法删除消息:缺少时间戳", "cannot_delete_invalid_timestamp": "无法删除消息:时间戳无效", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index c635769891..a11e2ac30f 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -98,6 +98,8 @@ }, "message": { "no_active_task_to_delete": "沒有可刪除訊息的活躍工作", + "no_active_task_to_roll_back": "沒有可還原的活動任務", + "no_active_task_to_restore": "沒有可復原的活動任務", "invalid_timestamp_for_deletion": "刪除操作的訊息時間戳無效", "cannot_delete_missing_timestamp": "無法刪除訊息:缺少時間戳", "cannot_delete_invalid_timestamp": "無法刪除訊息:時間戳無效", diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index f1e11ea003..32be9b67e9 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -283,8 +283,11 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( // lexically `link/sneaky.txt` is inside the workspace. await fs.symlink(outsideDir, path.join(service.workspaceDir, "link"), "dir") + // "resolves outside the workspace" is the real-path (symlink) + // guard's message; the bare "outside the workspace" would also + // match the lexical guard's error. await expect(service.restoreFile(commit1!.commit, path.join("link", "sneaky.txt"))).rejects.toThrow( - /outside the workspace/, + /resolves outside the workspace/, ) // The outside file survives: the restore failed before any mutation. diff --git a/webview-ui/src/components/chat/ChangeCard.tsx b/webview-ui/src/components/chat/ChangeCard.tsx index daa1af2d3f..14986621bc 100644 --- a/webview-ui/src/components/chat/ChangeCard.tsx +++ b/webview-ui/src/components/chat/ChangeCard.tsx @@ -210,7 +210,12 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { case "error": return ( + {/* Focusable status so the error detail is reachable by keyboard and + screen-reader users, not only via the hover tooltip. */} @@ -280,7 +285,11 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { case "error": return ( + {/* Focusable status (see the file-rollback error span above). */} @@ -349,7 +358,11 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => { case "error": return ( + {/* Focusable status (see the file-rollback error span above). */} @@ -407,8 +420,8 @@ export const ChangeCard = ({ message }: { message: ClineMessage }) => {