Repository navigation
feat(tools): publish apply_patch through the guard (U6, #1375) #1915
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c4a9ae2
9003730
6c331da
ed7274f
88c4afa
166073f
f491af8
188b2f3
91efcb1
3920f65
05b6aa2
427e141
82eb3f8
fae26bc
ba493f4
aaa12cb
ea76274
ef18c7c
84c95ba
31d08da
89624ce
e8cffea
a3be4a6
59176fb
22179e4
8459cba
2c61f0c
ff80d49
3ebd9ce
da74317
c6c6abf
fdb0977
f56e02a
f241311
f29b11b
5fa2179
32d1fa6
11848e6
9075fdd
cc8d413
8c55b29
6551af7
5529fbc
5218aea
4d2bfb4
758e74c
55a6598
d10f0f2
22ee292
c1d3021
b8a5cfd
8795844
e0ca1db
52b7cc6
2b82937
4fbf99a
957b1b1
f4edf2c
23f0e8e
567bf8f
cc9bb4a
688053b
9610329
fd25bba
9f5c801
4b6cf13
8d8901b
77070a2
f1371d8
433d15b
98be920
e153075
c411f97
1e8b458
51d912a
38feec8
b7ee4a5
993fc6f
440dca4
0b0db14
bc288fd
9473a26
7f3ee12
9373761
ec57ad4
fd1f690
2d90581
57cd65f
ae30e89
f8ee3e8
8a29fae
3345170
c6c92f6
5928693
9d53a04
94f4a23
42a1bbb
a71761a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| 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") | ||
| }) | ||
|
|
||
| describe("completeness scope (S4b follow-up #46)", () => { | ||
| it("defaults to a complete observation when the read scope is not given", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
|
|
||
| expect(reg.get("/a/b/c.ts")!.complete).toBe(true) | ||
| }) | ||
|
|
||
| it("records a partial observation when the read only returned a view of the file", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1", false) | ||
|
|
||
| expect(reg.get("/a/b/c.ts")!.complete).toBe(false) | ||
| }) | ||
|
|
||
| it("re-observing replaces the entry's completeness with the new read's scope", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1", false) | ||
| reg.observe("/a/b/c.ts", "v2") | ||
|
|
||
| const obs = reg.get("/a/b/c.ts")! | ||
| expect(obs.version).toBe("v2") | ||
| expect(obs.complete).toBe(true) | ||
| }) | ||
|
|
||
| it("re-observing with a partial scope downgrades a previously complete entry", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| reg.observe("/a/b/c.ts", "v2", false) | ||
|
|
||
| const obs = reg.get("/a/b/c.ts")! | ||
| expect(obs.version).toBe("v2") | ||
| expect(obs.complete).toBe(false) | ||
| }) | ||
| }) | ||
| describe("forget", () => { | ||
| it("drops the observation for one path and reports that it did", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| reg.observe("/a/b/d.ts", "v2") | ||
| const sizeBefore = reg.size | ||
|
|
||
| expect(reg.forget("/a/b/c.ts")).toBe(true) | ||
|
|
||
| // The three effects the row names: gone from the registry, one smaller, and a true | ||
| // result. Asserted together because a delete that returned the wrong boolean, or that | ||
| // cleared more than it was asked to, would pass a size-only check. | ||
| expect(reg.get("/a/b/c.ts")).toBeUndefined() | ||
| expect(reg.has("/a/b/c.ts")).toBe(false) | ||
| expect(reg.size).toBe(sizeBefore - 1) | ||
| // The reason forget() exists rather than clear(): the other authorization survives. | ||
| expect(reg.get("/a/b/d.ts")?.version).toBe("v2") | ||
| }) | ||
|
|
||
| it("reports false when the path was never observed", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
|
|
||
| expect(reg.forget("/absent.ts")).toBe(false) | ||
| expect(reg.size).toBe(1) | ||
| expect(reg.get("/a/b/c.ts")?.version).toBe("v1") | ||
| }) | ||
|
|
||
| it("reports false when the same path is forgotten twice", () => { | ||
| const reg = new ObservationRegistry() | ||
| reg.observe("/a/b/c.ts", "v1") | ||
| expect(reg.forget("/a/b/c.ts")).toBe(true) | ||
|
|
||
| // A second revoke must not report a success it did not earn: the caller uses the | ||
| // boolean to tell "I dropped the authorization" from "there was nothing to drop". | ||
| expect(reg.forget("/a/b/c.ts")).toBe(false) | ||
| expect(reg.size).toBe(0) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * 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. The S4 guarded-write consults | ||
| * these observations for the version check and for the completeness check that | ||
| * gates a full-file replacement. | ||
| */ | ||
|
|
||
| export interface FileObservation { | ||
| /** Version token derived from on-disk fs.stat (bigint mode). */ | ||
| version: string | ||
| /** Millisecond timestamp when the observation was recorded. */ | ||
| observedAt: number | ||
| /** | ||
| * Whether the read that produced this observation returned the complete | ||
| * file. A slice, line-range, truncated, or indentation-block read returns | ||
| * only a view of the file; such an observation authorizes targeted edits | ||
| * on the view the model saw, but never a full-file replacement. | ||
| */ | ||
| complete: boolean | ||
| } | ||
|
|
||
| export class ObservationRegistry { | ||
| private readonly entries = new Map<string, FileObservation>() | ||
|
|
||
| /** | ||
| * Record an observation for a file at its absolute path. | ||
| * | ||
| * Re-observing replaces the entry with a fresh observedAt timestamp, the | ||
| * new version token, and the read's completeness. `complete` defaults to | ||
| * true for callers that read the whole file themselves (spec doubles, | ||
| * WriteToFileTool). A caller whose read is internal to a targeted edit must | ||
| * carry the model's prior completeness instead, so the tool's own read cannot | ||
| * upgrade a partial read into authority for a full-file replacement. | ||
| */ | ||
| observe(absolutePath: string, version: string, complete: boolean = true): void { | ||
| this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) | ||
| } | ||
|
|
||
| get(absolutePath: string): FileObservation | undefined { | ||
| return this.entries.get(absolutePath) | ||
| } | ||
|
|
||
| has(absolutePath: string): boolean { | ||
| return this.entries.has(absolutePath) | ||
| } | ||
|
|
||
| /** | ||
| * Drop the observation for one path. A caller that must revoke an | ||
| * authorization it did not earn - a preview that observed a version the model | ||
| * never read - needs this instead of clear(), which would also discard the | ||
| * observations other reads of the same task still rely on. | ||
| */ | ||
| forget(absolutePath: string): boolean { | ||
| return this.entries.delete(absolutePath) | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.entries.clear() | ||
| } | ||
|
|
||
| get size(): number { | ||
| return this.entries.size | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { TelemetryService } from "@roo-code/telemetry" | |
|
|
||
| import { getReadablePath } from "../../utils/path" | ||
| import { isPathOutsideWorkspace } from "../../utils/pathUtils" | ||
| import { versionTokenOfStat } from "../../utils/versionToken" | ||
| import { Task } from "../task/Task" | ||
| import { formatResponse } from "../prompts/responses" | ||
| import { fileExistsAtPath } from "../../utils/fs" | ||
|
|
@@ -74,7 +75,32 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| return | ||
| } | ||
|
|
||
| // The diff below is built from this exact read, so the save that follows must be | ||
| // authorized against the version captured here - not against whatever version the | ||
| // preview happens to stat afterwards. Same contract as ApplyPatchTool's hunk read: | ||
| // stat around the read and observe only when the file did not change underneath it. | ||
| 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)) { | ||
| // A tool read is not a model read. With no prior observation this stays a | ||
| // partial observation of the version the diff was computed against - the only | ||
| // authorization the save can have, since apply_diff computes its hunks from | ||
| // this read. When the model already observed the file, keep the completeness it | ||
| // earned, but only on the version it was earned on: refreshing an OLDER | ||
| // observation to the current version would let content the model built from a | ||
| // stale read pass the compare-and-swap, so an out-of-date observation is left | ||
| // alone and the save fails with the re-read remediation. | ||
| const prior = task.observationRegistry.get(absolutePath) | ||
| if (prior === undefined) { | ||
| task.observationRegistry.observe(absolutePath, preReadToken, false) | ||
| } else if (prior.version === preReadToken) { | ||
| task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Apply the diff to the original content | ||
| const diffResult = (await task.diffStrategy?.applyDiff( | ||
|
|
@@ -177,7 +203,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( | ||
|
|
@@ -186,6 +213,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| false, | ||
| diagnosticsEnabled, | ||
| writeDelayMs, | ||
| "edit", | ||
| ) | ||
| } else { | ||
| // Original behavior with diff view | ||
|
|
@@ -225,7 +253,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { | |
| } | ||
|
|
||
| // Call saveChanges to update the DiffViewProvider properties | ||
| await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) | ||
| await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") | ||
|
easonLiangWorldedtech marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| // Track file edit operation | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.