Skip to content

fix(acp): stage file edits for native review instead of writing twice#38198

Open
anthony-furman wants to merge 15 commits into
anomalyco:devfrom
anthony-furman:acp-native-review
Open

fix(acp): stage file edits for native review instead of writing twice#38198
anthony-furman wants to merge 15 commits into
anomalyco:devfrom
anthony-furman:acp-native-review

Conversation

@anthony-furman

Copy link
Copy Markdown

Issue for this PR

Closes #38196
Related to #4240 (previously tracked this, closed when the ACP implementation was refactored in #29929, but the underlying gap was never actually fixed by that refactor)

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

This rebases @PacoDw's closed PR #31392 (feat/acp-review-at-end) onto current dev. That PR was the most complete fix for #4240, but got auto-closed by the repo's inactivity-cleanup bot (>1 month old, <2 reactions) rather than on its merits, and had drifted from dev since the ACP-next promotion (#29929).

The core problem: when an ACP client (e.g. Zed) advertises the fs.writeTextFile capability, OpenCode still writes edits to disk itself via its own afs.writeWithDirs(...), and acp/permission.ts also unconditionally fires a writeTextFile RPC to the client with the same final content once the edit permission is approved — initialize never stored clientCapabilities.fs.writeTextFile anywhere, so nothing could gate on it. Two effects: (1) the client's native "Keep/Reject" review UI never appears, since the file is already mutated by the time the client would show it, and (2) two separate write signals go out for what's logically one edit.

The fix adds a ReviewOverlay/ReviewFs staging layer (packages/core/src/review-overlay.ts, packages/opencode/src/effect/review-fs-layer.ts):

  • service.ts now reads params.clientCapabilities.fs.writeTextFile at initialize and stores it (review-mode.ts).
  • When the client has that capability (and OpenCode is running as opencode acp), edit/write/apply_patch write into an in-memory overlay instead of real disk; the staged content is sent to the client via a single writeTextFile call, and the client's own write/review decision becomes the only mutation. permission.ts skips its old unconditional mirror write in this mode (!isActive() guard) so the change isn't reflected twice.
  • Deletes/renames in apply_patch still go straight to disk (ACP has no delete/rename primitive), clearly called out in tool output.
  • Clients without the capability get the exact previous behavior — direct disk write, no writeTextFile RPC.
  • The overlay is cleared at the end of every turn (success, error, or cancel).

What I did on top of the original PR: cherry-picked the 9 substantive commits (skipping the branch's various Merge branch 'dev' / chore: generate commits, which only carried unrelated doc/i18n churn and caused hundreds of spurious conflicts on a straight rebase) onto current dev, preserving original authorship. All 9 applied with zero conflicts.

How did you verify your code works?

  • bun run typecheck (via tsgo --noEmit) — clean, no errors.
  • bun test test/acp test/effect/review-fs-layer.test.ts test/effect/httpapi-review-fs.test.ts test/tool/review-mode-tools.test.ts — 151 pass, 0 fail (includes the new review-mode, review-staging, review-fs-layer suites).
  • bun test test/tool/edit.test.ts test/tool/write.test.ts test/tool/apply_patch.test.ts (legacy, no-capability path) — 71 pass, 0 fail, confirming no regression for clients without fs.writeTextFile.
  • bun test test/review-overlay.test.ts (packages/core) — 5 pass, 0 fail.

The original PR includes recordings of a fresh end-to-end session against Zed/Devin (linked below), and this rebase didn't touch the ACP wire format, only adapted the wiring to the current dev structure. If deemed necessary, I would be willing to run it again and update the PR.

Screenshots / recordings

See the original PR for recordings against Zed and Devin: #31392

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

PacoDw added 9 commits July 21, 2026 21:28
In-memory overlay that holds staged file content during an ACP turn so
add/update writes can be sent to the client for native review instead of
hitting disk immediately.

(cherry picked from commit e8e0244)
Gate review staging on the client writeTextFile capability, flush staged
edits through ACP, and wrap FSUtil so tool writes land in the overlay
instead of disk while review mode is active.

(cherry picked from commit 8573277)
Wire review mode into ACP session lifecycle, tool registry, and edit/write/
apply_patch tools so add/update changes reach the client review UI via
writeTextFile. Delete/move still hit disk and show as tool_call diffs.

(cherry picked from commit f71e111)
Upstream dropped @opencode-ai/core/util/log; migrate ACP review paths to
Effect.logInfo/logError so typecheck passes after the rebase.

(cherry picked from commit 4e58f07)
ToolRegistry.defaultLayer already provides Agent; align the registry test
layer with skill.test and use an inline build agent to satisfy Effect types.

(cherry picked from commit b6d4823)
… migration

Re-add ReviewFs to ToolRegistry.node and createRoutes so tool writes stage
in memory again. Remove misplaced ReviewFs from AppRuntime, which ACP does
not use.

(cherry picked from commit 13fb2e6)
Verify ToolRegistry.node with ReviewFs stages edits through the same layer
wiring used by the HTTP server after the LayerNode migration.

(cherry picked from commit 44506c9)
Adapt ReviewFs.node and the HTTP review regression test to the new
LayerNode.make object form and compile() after upstream rebase.

(cherry picked from commit 2b32aaf)
Replace removed FSUtil.defaultLayer usage with compile-based ReviewFs nodes
and global FSUtil replacement in the HTTP app graph so ACP review staging
keeps working on the current dev stack.

(cherry picked from commit 1c91651)
@NuDheeraj

Copy link
Copy Markdown

would be great if this fix is merged

flushPendingWrites runs twice per turn (once on tool completion, once
at end-of-turn), and enqueueUnflushed() had no memory of what it had
already drained. Since entries isn't cleared until end-of-turn, the
second flush re-queued unchanged content and sent a duplicate
fs/write_text_file for the same edit. Track flushed content per path
so unchanged entries aren't resent, while genuine re-edits still are.

Adds a regression test in review-overlay.test.ts reproducing the
double-flush directly, plus an end-to-end ACP test asserting the
client sees the tool_call_update before exactly one write, and that
opencode never writes the file itself.
@anthony-furman

Copy link
Copy Markdown
Author

tested and noticed a duplication of the acp log for fs write, fixed

@3ugen

3ugen commented Jul 23, 2026

Copy link
Copy Markdown

I think a rejected fs/write_text_file can currently turn into a false successful edit.

drainPendingWrites() removes the item and records flushedContent before the RPC runs. flushPendingWrites() then catches a rejection and resolves, while the event path has already sent the completed tool update. The end-of-turn enqueueUnflushed() skips the content as already flushed, and clear() drops the remaining overlay entry. In a focused fixture with a rejecting writeTextFile, I get one RPC call, an empty pending queue, no end-of-turn retry, and the real file is still unchanged.

Would it make sense to acknowledge/remove each item only after the RPC succeeds, and make rejection fail the tool/turn, with a regression test for a rejecting client?

@PacoDw

PacoDw commented Jul 23, 2026

Copy link
Copy Markdown

Thank you so much for rescuing this, @anthony-furman ! I didn't have time to rebase it these last few weeks, so I'm really happy you picked it up. Great job finding and fixing the double-flush bug too, and thanks for keeping the original commit authorship 🚀

@anthony-furman

Copy link
Copy Markdown
Author

Good catch @3ugen . I made it fail the turn, since it's not really the tool that failed in this case, and I'm not sure how different ACP clients would handle resending an already completed tool call as failed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

acp: edits are written to disk directly instead of going through fs.writeTextFile, so native review UI never shows

4 participants