diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b911479bc6..0f26e70727 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -107,6 +107,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 { DispatchState, type PendingWork } from "./dispatchState" @@ -302,6 +303,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__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c1ee5a0b13..e1cefe8a9e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1761,6 +1761,46 @@ describe("Cline", () => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) }).toThrow("Either historyItem or task/images must be provided") }) + + it("keeps the observation registry private to each task in a parent/subtask pair", () => { + // The write guard compares an observed token against the file on disk, so a + // subtask must neither inherit nor overwrite what the parent observed: each + // Task builds its own registry rather than borrowing the parent's. + const parent = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "parent task", + startTask: false, + }) + const child = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "child task", + parentTask: parent, + rootTask: parent, + startTask: false, + }) + + // The constructor records the parent object plus the root/parent ids; the + // rootTask object field is filled in by callers (ClineProvider), not by the + // constructor, so the id link is what this boundary can assert. + expect(child.parentTask).toBe(parent) + expect(child.parentTaskId).toBe(parent.taskId) + expect(child.rootTaskId).toBe(parent.taskId) + expect(child.observationRegistry).not.toBe(parent.observationRegistry) + + // The same path recorded in both tasks, with different tokens. + parent.observationRegistry.observe("/workspace/shared.ts", "1:2:3:4:5") + child.observationRegistry.observe("/workspace/shared.ts", "9:8:7:6:5") + + expect(parent.observationRegistry.get("/workspace/shared.ts")?.version).toBe("1:2:3:4:5") + expect(child.observationRegistry.get("/workspace/shared.ts")?.version).toBe("9:8:7:6:5") + + // Neither direction leaks: dropping the child's entry leaves the parent's. + child.observationRegistry.forget("/workspace/shared.ts") + expect(child.observationRegistry.get("/workspace/shared.ts")).toBeUndefined() + expect(parent.observationRegistry.get("/workspace/shared.ts")?.version).toBe("1:2:3:4:5") + }) }) describe("updateApiConfiguration", () => { diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..f1b0fa4d5b --- /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 the clock time it observed at", () => { + // typeof number accepts 0 and NaN, so pin the exact timestamp instead: a + // broken clock that returns some other number must not pass. + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2025, 0, 1, 0, 0, 0, 0))) + try { + 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(obs!.observedAt).toBe(Date.UTC(2025, 0, 1, 0, 0, 0, 0)) + } finally { + vi.useRealTimers() + } + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + try { + 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) + } finally { + // Teardown in finally so a failing assertion cannot leak fake + // timers or the mocked Date into subsequent tests. + vi.useRealTimers() + } + }) + + it("forget drops a warm entry so a later read cannot be guarded by a stale token", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + + // The warm case: an entry from an earlier read must not survive a read that could not + // compute a token for the current on-disk version. + expect(reg.forget("/x.ts")).toBe(true) + expect(reg.get("/x.ts")).toBeUndefined() + + // The cold case: forgetting an unobserved path is a no-op, not an error. + expect(reg.forget("/y.ts")).toBe(false) + expect(reg.get("/y.ts")).toBeUndefined() + }) + + 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..b01baacc00 --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,49 @@ +/** + * 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. Nothing in this PR reads an + * observation back out; the guard that will compare these tokens is a later phase. + * + * Only observe, forget and get are exposed: the S4 guard is the only named consumer. A + * per-task instance needs no reset - the next task constructs a new registry - so has, size + * and clear land with whoever needs them (a registry shared across tasks would need the + * reset, and that is not decided yet). + */ + +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() }) + } + + /** + * Drop the observation for a file. A read that cannot compute a version token must not + * leave an earlier entry behind, because the write guard would then compare against a + * token that no longer describes the last read. + */ + forget(absolutePath: string): boolean { + return this.entries.delete(absolutePath) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } +} diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index e4e69b30ad..bc9ae12e3f 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -17,6 +17,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" @@ -215,12 +216,17 @@ 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) + // Observe the on-disk version before reading so the token can never be newer than + // the bytes this read returns; see observeReadVersion. + const version = await computeVersionToken(fullPath).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") const result = this.processTextFile(fileContent, entry) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + observeReadVersion(task, fullPath, version) + updateFileResult(relPath, { nativeContent: `File: ${relPath}\n${result}`, }) @@ -770,6 +776,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file + // Observe the on-disk version before reading so the token can never be newer than + // the bytes this read returns; see observeReadVersion. + const version = await computeVersionToken(fullPath).catch(() => undefined) const rawContent = await fs.readFile(fullPath, "utf8") // Handle line ranges if specified @@ -801,6 +810,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + observeReadVersion(task, fullPath, version) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) @@ -816,3 +827,24 @@ export class ReadFileTool extends BaseTool<"read_file"> { } export const readFileTool = new ReadFileTool() + +/** + * Record (or drop) the version of a file this tool just read, in one place so the + * native and legacy read paths cannot drift apart. + * + * A present token is stored as observed. A missing token means the stat failed, so + * the target is unobserved and any earlier entry must be dropped: that token came from + * a previous read and no longer describes this file, and leaving it would let the write + * guard compare against a stale version. The token itself is taken before the read by + * both call sites, which is deliberate - a token taken afterwards could be newer than + * the content the model saw, and a guard built on it would green-light a stale write. + * The conservative direction is the one chosen here: a mutation during the read yields + * a token that mismatches and blocks the guarded write. + */ +function observeReadVersion(task: Task, fullPath: string, version: string | undefined): void { + if (version) { + task.observationRegistry.observe(fullPath, version) + } else { + task.observationRegistry.forget(fullPath) + } +} diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 52e04415ea..9dd5e1c606 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -14,6 +14,9 @@ import os from "os" import path from "path" +import type { BigIntStats, Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import type { ToolUse } from "../../../shared/tools" import type { Task, ToolExecutionContext } from "../../task/Task" @@ -21,6 +24,7 @@ import type { Task, ToolExecutionContext } from "../../task/Task" import { isBinaryFile } from "isbinaryfile" import { readFileTool } from "../ReadFileTool" +import { ObservationRegistry } from "../../task/observationRegistry" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" import { extractTextFromFile, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" @@ -135,6 +139,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -142,6 +147,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 }, @@ -183,12 +191,32 @@ function createMockCallbacks() { // ─── Tests ──────────────────────────────────────────────────────────────────── +// BigIntStats is a class-backed interface with no public constructor, so a plain-object double +// is the only practical way to pin a version token without touching real files. Same shape as +// versionToken.spec.ts's local makeStats: single-step downcast from Partial to the full type, +// kept test-local here because this file hands the double to a fs.stat mock typed as Stats +// (the *Ns fields are what the token reads), which is a last-resort double assertion per AGENTS.md. +function makeStats(overrides: Partial = {}): Stats { + const base: Partial = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + return { ...base, ...overrides } as unknown as Stats +} + describe("ReadFileTool", () => { beforeEach(() => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // Full BigIntStats shape: a partial double makes computeVersionToken throw a + // TypeError that its .catch swallows, which launders a mock gap into "no token" + // and silently skips observation in every test that does not override this mock. + mockedFsStat.mockResolvedValue(makeStats()) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -1688,5 +1716,397 @@ 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(makeStats()) + 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).toBe(path.resolve("/test/workspace", "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("observes the token taken before the read, not the one after it", async () => { + // A token computed after the read can describe bytes the model never saw: if the + // file changes between the read and the stat, the registry ends up holding a newer + // token than the content the tool returned, and the write guard would then accept a + // stale write. This fixture mutates the file inside the read itself, so the pre-read + // and post-read tokens differ and the assertion names which one may be recorded. The + // direction is chosen deliberately: a mid-read mutation must mismatch and block the + // guarded write, which is the conservative answer for a guard. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + // The directory-check stat and the token stat share this mock. Only the token + // lookup passes { bigint: true }, so the mutation flag decides which token shape it + // returns; readStarted records whether the read had already happened. + let readStarted = false + mockedFsStat.mockImplementation(async (_path: unknown, options?: { bigint?: boolean }) => + makeStats({ + mtimeNs: BigInt(options?.bigint && readStarted ? 9_000_000_000n : 4_000_000_000n), + }), + ) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockImplementation(async () => { + readStarted = true + return Buffer.from("content") + }) + 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) + expect(observeSpy).toHaveBeenCalledTimes(1) + const [, calledVersion] = observeSpy.mock.calls[0] + // 4000000000 is the pre-read mtimeNs; 9000000000 is the mutated file. + expect(calledVersion).toBe("1:2:300:4000000000:5000000000") + expect(readStarted).toBe(true) + }) + + it("keeps the earlier observation when the context tracker rejects (native path)", async () => { + // trackFileContext swallows its own failures in production (FileContextTracker.ts:81-95 puts its + // whole body in a try whose catch only console.errors), so a rejection here can only come from + // this double. What the case pins is where the read stands when it does: the failure lands in the + // surrounding catch, which replaces the not-yet-recorded content with an error row and marks the + // turn failed, and observeReadVersion - which sits after the tracker call - never runs, so the + // entry from the earlier read stays exactly as it was. An older token mismatches a changed file, + // so the leftover blocks a later guarded write rather than green-lighting it. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + const reg = mockTask.observationRegistry! + const fullPath = path.resolve("/test/workspace", "existing.ts") + // An observation from an earlier read, so the case can name what a skipped observe leaves: + // the older token, not a refreshed one and not an absence. + reg.observe(fullPath, "seed:1:2:300:1000000000:1000000001") + const observeSpy = vi.spyOn(reg, "observe") + mockTask.fileContextTracker.trackFileContext.mockRejectedValueOnce(new Error("tracker exploded")) + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + // The read itself succeeded; the error the model sees is the tracker's. + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("tracker exploded")) + expect(String(callbacks.pushToolResult.mock.calls[0][0])).not.toContain("test content") + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("tracker exploded")) + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledTimes(1) + expect(observeSpy).toHaveBeenCalledTimes(0) + expect(reg.get(fullPath)?.version).toBe("seed:1:2:300:1000000000:1000000001") + }) + + it("keeps its content and the earlier observation when the tracker rejects (legacy path)", async () => { + // The legacy branch reaches the same conclusion by a different route, and the difference is + // the part worth pinning: it pushes the file's content into the results before it calls the + // tracker, so a rejection there leaves the content in the tool result *and* appends an error + // row, while the observation is left exactly as the earlier read put it. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + mockedFsReadFile.mockResolvedValue("legacy content") + const reg = mockTask.observationRegistry! + const fullPath = path.resolve("/test/workspace", "legacy.ts") + reg.observe(fullPath, "seed:1:2:300:1000000000:1000000001") + const observeSpy = vi.spyOn(reg, "observe") + mockTask.fileContextTracker.trackFileContext.mockRejectedValueOnce(new Error("tracker exploded")) + 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(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("1 | test content")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Error: tracker exploded"), + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("tracker exploded")) + expect(observeSpy).toHaveBeenCalledTimes(0) + expect(reg.get(fullPath)?.version).toBe("seed:1:2:300:1000000000:1000000001") + }) + + it("a failed read (absent path) records no observation for the path it failed on 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!.get(path.resolve("/test/workspace", "missing.ts"))).toBeUndefined() + }) + + it("a failed version-token lookup after a successful read keeps the result and leaves the registry empty", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // The directory-check stat succeeds; the stat inside computeVersionToken + // rejects, so the token lookup fails after the file was read successfully. + const okStats = makeStats() + mockedFsStat.mockResolvedValueOnce(okStats) + mockedFsStat.mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself succeeded: no failure flag and the result carries the file + // and its content (not just the path). + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("existing.ts")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("content")) + + // The failed token lookup never observes: with a fresh registry the only path this read + // touches is the one it read, so target-absence is what "stays empty" means here. + }) + + it("drops the stale observation when the version-token lookup fails after an earlier successful read", async () => { + // Warm-state companion to the cold test above, which starts from an empty + // registry. Here the registry already holds a token from a previous read of + // the same path, so the failed lookup has to drop it: otherwise the write + // guard keeps comparing against a token that no longer describes the file. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const okStats = makeStats() + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + // Each read stats the path twice: once to rule out a directory and once for + // the version token. Only the token lookup is made to fail. + // Only computeVersionToken asks for bigint stats, so arm the failure on that + // call rather than on call parity, which ties the test to how many stats a + // read happens to make. + let failTokenLookup = false + mockedFsStat.mockImplementation(async (_path: unknown, options?: { bigint?: boolean }) => { + // Cast: the mock only needs the option the real caller passes. + if (failTokenLookup && options?.bigint === true) { + throw new Error("EACCES") + } + return okStats + }) + + // Seed: a successful read records an observation for the resolved path. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + expect(observeSpy).toHaveBeenCalledTimes(1) + const [observedPath, observedVersion] = observeSpy.mock.calls[0] + expect(reg.get(observedPath)?.version).toBe(observedVersion) + + // The next read of the same path loses its token lookup. + failTokenLookup = true + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself still succeeded: content is preserved, no failure flag. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("existing.ts")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("content")) + + // The failed lookup observed nothing and dropped the stale entry. + expect(observeSpy).toHaveBeenCalledTimes(1) + expect(reg.get(observedPath)).toBeUndefined() + }) + + it("a failed version-token lookup on a legacy read keeps the result and leaves the registry empty", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Same shape as the native case, on the legacy multi-file path: + // directory-check stat succeeds, the token stat rejects after the read. + const okStats = makeStats() + mockedFsStat.mockResolvedValueOnce(okStats) + mockedFsStat.mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue("legacy content") + // The legacy path slices the raw content through readWithSlice, so the slice + // mock must carry the legacy content for the result assertion below. + mockedReadWithSlice.mockReturnValue({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // 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) + + // The legacy read succeeded: no failure flag and the result carries the file + // and its content (not just the path). + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("legacy.ts")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("legacy content")) + + // The failed token lookup never observes: with a fresh registry the only path this read + // touches is the one it read, so target-absence is what "stays empty" means here. + }) + + it("drops the stale observation when a legacy read loses its version token after an earlier successful read", async () => { + // Warm-state companion on the legacy multi-file path, for the same reason as + // the native case above: the entry from the first read must not survive a + // token lookup that failed on the second. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const okStats = makeStats() + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue("legacy content") + // The legacy path slices the raw content through readWithSlice. + mockedReadWithSlice.mockReturnValue({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // Each read stats the path twice: once to rule out a directory and once for + // the version token. Only the token lookup is made to fail. + // Only computeVersionToken asks for bigint stats, so arm the failure on that + // call rather than on call parity, which ties the test to how many stats a + // read happens to make. + let failTokenLookup = false + mockedFsStat.mockImplementation(async (_path: unknown, options?: { bigint?: boolean }) => { + // Cast: the mock only needs the option the real caller passes. + if (failTokenLookup && options?.bigint === true) { + throw new Error("EACCES") + } + return okStats + }) + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + expect(observeSpy).toHaveBeenCalledTimes(1) + const [observedPath, observedVersion] = observeSpy.mock.calls[0] + expect(observedPath).toBe(path.resolve("/test/workspace", "legacy.ts")) + expect(reg.get(observedPath)?.version).toBe(observedVersion) + + failTokenLookup = true + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + // The legacy read still succeeded: content is preserved, no failure flag. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("legacy.ts")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("legacy content")) + + expect(observeSpy).toHaveBeenCalledTimes(1) + expect(reg.get(observedPath)).toBeUndefined() + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(makeStats()) + 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).toBe(path.resolve("/test/workspace", "legacy.ts")) + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("the legacy path takes its token before the read too", async () => { + // Same fixture as the native ordering case above, run through the typed legacy params: the + // legacy branch has its own stat-then-read pair at ReadFileTool.ts:781, and a token taken + // after its read would put a newer token in the registry than the content the model saw. + // The file mutates mid-read, so the two tokens differ and the assertion names which one + // may be recorded. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + let readStarted = false + mockedFsStat.mockImplementation(async (_path: unknown, options?: { bigint?: boolean }) => + makeStats({ + mtimeNs: BigInt(options?.bigint && readStarted ? 9_000_000_000n : 4_000_000_000n), + }), + ) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockImplementation(async () => { + readStarted = true + return `content` + }) + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + 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 [, calledVersion] = observeSpy.mock.calls[0] + // 4000000000 is the pre-read mtimeNs; 9000000000 is the mutated file. + expect(calledVersion).toBe(`1:2:300:4000000000:5000000000`) + expect(readStarted).toBe(true) + }) + }) }) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 42be14463c..cb6575a316 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -861,7 +861,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 96 + "count": 95 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": {