Skip to content

feat(acp): delegate developer file I/O through ACP fs methods#6940

Open
rabi wants to merge 1 commit intoblock:mainfrom
rabi:zed_acp
Open

feat(acp): delegate developer file I/O through ACP fs methods#6940
rabi wants to merge 1 commit intoblock:mainfrom
rabi:zed_acp

Conversation

@rabi
Copy link
Contributor

@rabi rabi commented Feb 4, 2026

Summary

When an ACP client (e.g. Zed) advertises fs.writeTextFile and fs.readTextFile capabilities, the developer extension routes file reads/writes through the client instead of hitting disk directly. This lets the editor track modifications and return unsaved buffer contents.

Type of Change

  • Feature
  • Bug fix
  • Refactor / Code quality
  • Performance improvement
  • Documentation
  • Tests
  • Security fix
  • Build / Release
  • Other (specify below)

AI Assistance

  • This PR was created or reviewed with AI assistance

Testing

Tested locally with zed.

Related Issues

Relates to #6894

Screenshots/Demos (for UX changes)

After:
image

@michaelneale
Copy link
Collaborator

hrm - yeah this is not a small change, although pretty cool. Main thing is this changes the developer tool to have a new mode, that is main thing that concerns, but otherwise would make editor experience nicer, wonder if there is a way to package this to not be as cross cutting, as you will rarely want "write mode" etc?

@rabi
Copy link
Contributor Author

rabi commented Feb 19, 2026

hrm - yeah this is not a small change, although pretty cool. Main thing is this changes the developer tool to have a new mode, that is main thing that concerns, but otherwise would make editor experience nicer, wonder if there is a way to package this to not be as cross cutting, as you will rarely want "write mode" etc?

It's based on client capability to write. If you think there could be scenarios where clients won't need it irrespective of them advertising their capabilities, we can probably make it configurable, but we would still need the new developer mode.

        let client_can_write = self
            .client_capabilities
            .get()
            .is_some_and(|c| c.fs_write_text_file);
        let write_mode = if client_can_write && !has_code_execution {
            WriteMode::Deferred
        } else {
            WriteMode::Direct
        };

@rabi rabi force-pushed the zed_acp branch 4 times, most recently from fb56604 to ffd60ce Compare February 21, 2026 06:14
@michaelneale
Copy link
Collaborator

@rabi yeah - I think that we really want this only available when actually used from visual editors don't you think? otherwise it is adding more work for the LLM to do? if could polish that off I think is a great idea

@michaelneale michaelneale self-assigned this Feb 22, 2026
@rabi
Copy link
Contributor Author

rabi commented Feb 23, 2026

@rabi yeah - I think that we really want this only available when actually used from visual editors don't you think? otherwise it is adding more work for the LLM to do? if could polish that off I think is a great idea

OK, Thanks @michaelneale, I'll expose the feature behind --acp-editor flag for clients to enable it . So it will be behind both the flag and client capability to write files. The same flag can be used for other features in the future too.

@codefromthecrypt
Copy link
Collaborator

Hey @rabi, I filed #7451 to capture what I think the minimal approach looks like here.

The core idea is the same (route file I/O through ACP), but the implementation can be a lot simpler if we lean on what the spec already defines:

  • Reads too, not just writes. fs/read_text_file means the editor can return unsaved buffer contents. This PR only handles writes.
  • No custom MIME type or _meta.old_text. The spec's fs/write_text_file just takes path + content. The client decides how to present diffs. We don't need application/x-goose-file-diff or FileDiff structs.
  • No --acp-editor flag. Capability negotiation during initialize is the feature gate. If the client advertises readTextFile/writeTextFile, delegate. If not, fall back to disk. No new CLI flags needed.
  • No double permission. The existing session/request_permission flow handles tool approval. A second managed-write permission prompt isn't in the spec.
  • No in-process duplex pipe. The developer extension's disk I/O functions (File::open, fs::write, read_to_string) just need to become pluggable so they can go through ACP instead of disk. No need to rewire how the extension connects.

The view_range to line+limit translation and the checklist in #7451 should give a clearer starting point. Happy to discuss any of it.

@rabi
Copy link
Contributor Author

rabi commented Feb 24, 2026

@codefromthecrypt Thanks for checking this and creating the issue as well.

Reads too, not just writes. fs/read_text_file means the editor can return unsaved buffer contents. This PR only handles writes.

We can implement these incrementally right? I kept this limited to write ad read as a followup. As you can see I've added read_text_file in as TODO.

No custom MIME type or _meta.old_text. The spec's fs/write_text_file just takes path + content. The client decides how to present diffs. We don't need application/x-goose-file-diff or FileDiff structs.

Yeah, that's a good point. I'll change.

No --acp-editor flag. Capability negotiation during initialize is the feature gate. If the client advertises readTextFile/writeTextFile, delegate. If not, fall back to disk. No new CLI flags needed.

It was not in my original PR. Added after @michaelneale asked for it.

No double permission. The existing session/request_permission flow handles tool approval. A second managed-write permission prompt isn't in the spec.

I observed cases where approval didn’t trigger as expected in my testing, so I added a temporary guard. I’ll re-verify and remove if unnecessary.

No in-process duplex pipe. The developer extension's disk I/O functions (File::open, fs::write, read_to_string) just need to become pluggable so they can go through ACP instead of disk. No need to rewire how the extension connects.

Right, that would be larger refactor of developer extension with broader touch points I wanted to avoid, but I can include it if we want.

@rabi rabi changed the title feat(acp): buffer file diffs for client-side review feat(acp): delegate developer file I/O through ACP fs methods Feb 24, 2026
@codefromthecrypt
Copy link
Collaborator

thanks for accepting the feedback so far ;) ack that there are a couple design points going on.

Here's some unsolicited feedback with TL;DR; maybe chop off a targeted smaller PR from this, with eventually landing this as the goal. The first, is probably just tuning the developer extension (builtin) so that the impl of reads are possible to adapt in with an ACP client., This is like a wiring concern and shouldn't be huge. with that in, other things are simpler to progress maybe.


Something that can help is that when we look at this change, probably the easiest one is the read file not the write. One of the reasons it takes me so long to get changes together is that I make a PR (such as this one) then hack it to bits until all the pieces needed are in.

This is why #6605 has taken so long, for example. For example, the infra carve-out for MCP came in this, and in some ways it is similar to the design issue you are hinting at #6972

Basically, you have a chicken-egg, right? you are looking to have a way to control which impl is used for editing tools that Goose tells the LLM it has. So, how do you get ACP to be the "impl" of the developer plugin, especially considering the chicken-egg is you don't know if ACP will be supported by the client until after it connects.

So, one way to think about it is the "builtin" are already added to ACP via #6284 I mean that's how I was testing code mode, right? So, developer is a builtin.. so one way to chop this up is to choose the easiest thing, which may be read, not write, and do only that.. surgically, in a PR. It isn't required to do both read and write in the same one.. this would prove you've solved the bootstrapping concern in the right abstraction.

The other thing that might help, is while we have hints everywhere somethings are not very explicit. I force the agent to re-read this every few minutes to cut down on cruft..

CODING_STYLE.md

@rabi
Copy link
Contributor Author

rabi commented Feb 25, 2026

The first, is probably just tuning the developer extension (builtin) so that the impl of reads are possible to adapt in with an ACP client

I think I've addressed all the feedback in the last update, including developer extension changes. So it should be good now.

@michaelneale
Copy link
Collaborator

@rabi just today (I think?) there is a new developer tool, does this work with that? otherwise LGTM I think

@rabi
Copy link
Contributor Author

rabi commented Feb 26, 2026

@rabi just today (I think?) there is a new developer tool, does this work with that? otherwise LGTM I think

@michaelneale If you mean #7466, does not look like it's merged yet.

Copy link
Collaborator

@codefromthecrypt codefromthecrypt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm lightly available, so only had 2 hours to figure out the best advice to give on this PR, so here goes.

Thanks for addressing point comments like the _meta.old_text leak, the application/x-goose-file-diff MIME type, the --acp-editor flag, and the double permission prompt.

The biggest thing is that the archecture itself looks the same. I mentioned I wrote #7451 for you, but maybe it got lost in comments.

So, architecture: the ACP server shouldn't parse custom URI schemes or extract diffs from tool results. That whole goose-internal://file-diff/ flow exists because the old developer extension (developer__text_editor) couldn't call back to the server. But #7466 merged and rewrote the developer extension as a platform extension with flat tools (write, edit, shell, tree). You need to rebase anyway, and the new code makes the injection point obvious — edit.rs has three fs:: calls and that's it.

I updated #7451 with the new architecture, exact file paths on main, and what the spec requires.

Below is a prompt you can iterate on to help your coding agent implement this cleanly.

Details

Prompt: Implement ACP file I/O delegation for goose

You are implementing #7451 — ACP file system delegation for the goose agent. This lets editors (Zed, VS Code, Neovim) provide buffer contents on reads and track modifications on writes, instead of goose hitting disk directly.

Read the issue first: #7451 — it has the full spec, architecture diagram, implementation checklist, and test scenarios.

Study these PRs for style:

  • stakpak/agent#389: ACP fs capability gating in another agent (6 files, +170/-38 — small, focused)
  • block/goose#7115: per-session Agent refactor (7 files, balanced refactor)
  • block/goose#7466: the developer extension rewrite your PR must rebase onto

Phase 1: Research (read before coding)

Read the ACP file system spec:
https://agentclientprotocol.com/protocol/file-system

Key points:

  • fs/read_text_file: client-side method. Agent calls client, client returns buffer content. Params: sessionId, path, optional line (1-based) + limit.
  • fs/write_text_file: client-side method. Agent calls client with sessionId, path, content. That's it — no oldText, no diff, no metadata.
  • Capability negotiation: client sends clientCapabilities.fs.readTextFile / .writeTextFile in initialize. Agent checks before calling.
  • The LLM never sees these methods. It sees write, edit. The agent translates internally.

Read the tracking issue: #7451 — it has the architecture diagram, implementation checklist, and test scenarios.

Understand how editors handle diffs:

The agent sends full file content via one JSON-RPC call. The editor already has the old content (from its buffer or disk). The editor computes and displays the diff on its side:

  • Zed: receives fs/write_text_file(path, content), compares with its buffer, shows native diff in assistant panel (source)
  • codecompanion.nvim: same call, opens split window showing before/after using Neovim's native diff
  • acp.nvim: same call, shows inline permission text, :AcpViewDiff for review
  • vscode-acp: same call, delegates to registered callback for diff rendering

No diff logic belongs in the agent or server. The agent sends content; the editor diffs it.

Read the new developer extension on main (post-#7466):

The old DeveloperServer (MCP builtin, spawned via duplex pipe) is gone. The new code is a platform extension:

These three fs:: calls are your injection points. No TextFileIOStrategy enum, no SpawnServerFn changes, no builtin_context threading needed.

Phase 2: Architecture — where each responsibility belongs

Three layers:

ACP server (server.rs):

  • Parse clientCapabilities.fs from InitializeRequest (already done in your PR, keep this)
  • Store booleans, expose supports_fs_read() / supports_fs_write() (already done, keep this)
  • Provide ACP-backed read/write closures or trait impls to the developer extension via PlatformExtensionContext
  • Send ReadTextFileRequest / WriteTextFileRequest to client when called
  • Do NOT intercept tool results, parse URIs, or extract diffs

Developer platform extension (edit.rs):

  • EditTools calls read/write through an injected abstraction instead of std::fs directly
  • Default: std::fs (when no ACP, or capabilities absent)
  • ACP-provided: closures/trait that send JSON-RPC to client
  • No knowledge of ACP protocol, goose-internal:// URIs, or JSON-RPC
  • No TextFileIOStrategy enum — just "I have a read function and a write function"

Client (editor) — not goose code:

  • Receives fs/write_text_file, compares with buffer, shows diff
  • Receives fs/read_text_file, returns buffer content

Phase 3: Implementation steps

Step 0: Rebase on main (prerequisite)

  • developer__text_editor is gone. TextFileIOStrategy is gone. DeveloperServer is gone.
  • Start fresh from edit.rs on main.

Step 1: Make EditTools pluggable (developer extension only, ~50 lines)

Step 2: Wire ACP server to provide closures (server only, ~50 lines)

  • Parse capabilities (already works from your PR)
  • When readTextFile=true: provide a closure that sends ReadTextFileRequest
  • When writeTextFile=true: provide a closure that sends WriteTextFileRequest
  • Pass closures to developer extension via context
  • Files: server.rs, maybe PlatformExtensionContext
  • No changes to goose-mcp, extension_manager, builtin_extension, or SpawnServerFn

Step 3: Write in-process ACP tests

Goose already has an in-process test framework for ACP. Tests use OpenAiFixture (mocked LLM with pattern-matched SSE responses) and ClientToAgentConnection (in-process duplex transport). Read #6969 for context on how tests work and recording tips.

The existing test fixture at fixtures/server.rs already handles:

  • RequestPermissionRequest via PermissionDecision enum (AllowOnce, RejectOnce, Cancel, etc.)
  • SessionNotification collection for asserting tool_call_update status
  • OpenAiFixture for canned LLM responses without hitting a real API

You need to extend the fixture to also handle ReadTextFileRequest and WriteTextFileRequest (add handlers in the on_receive_request builder, similar to how RequestPermissionRequest is handled). Then add these test scenarios:

Scenario 1: Read delegation without permission

  • Initialize with clientCapabilities.fs.readTextFile = true
  • Prompt agent to read a file
  • Assert: ReadTextFileRequest received by client with correct path
  • Assert: RequestPermissionRequest NOT sent (reads don't need permission)
  • Assert: response contains file content

Scenario 2: Write delegation with permission approved

  • Initialize with clientCapabilities.fs.writeTextFile = true
  • Set PermissionDecision::AllowOnce
  • Prompt agent to write content to a file
  • Assert: RequestPermissionRequest sent first
  • Assert: WriteTextFileRequest received with correct path and content
  • Assert: tool_call_update status is completed
  • Assert: file exists on disk (client-side handler wrote it)

Scenario 3: Write delegation with permission rejected

  • Initialize with clientCapabilities.fs.writeTextFile = true
  • Set PermissionDecision::RejectOnce
  • Prompt agent to write content to a file
  • Assert: RequestPermissionRequest sent
  • Assert: WriteTextFileRequest NOT called
  • Assert: tool_call_update status is failed
  • Assert: file does NOT exist on disk

Scenario 4: Agent-side write (fs disabled)

  • Initialize WITHOUT writeTextFile capability
  • Set PermissionDecision::AllowOnce
  • Prompt agent to write content to a file
  • Assert: WriteTextFileRequest NOT sent (agent handles it locally)
  • Assert: RequestPermissionRequest still sent (permission still applies)
  • Assert: file exists on disk (written by agent via fs::write)

Scenario 5: Agent-side rejection (fs disabled)

  • Initialize WITHOUT writeTextFile capability
  • Set PermissionDecision::RejectOnce
  • Prompt agent to write content to a file
  • Assert: file does NOT exist on disk

To record the OpenAI fixture data for these tests, follow the steps in #6969: run goose acp --with-builtin developer with a real provider, capture the SSE responses, and extract request patterns from llm_request.*.jsonl.

Goose-specific protocol values:

  • allowOptionId: "allow_once", rejectOptionId: "reject_once"
  • rejectedToolStatus: "failed"
  • Spawn: goose acp --with-builtin developer

Phase 4: What NOT to recreate

Everything in this list exists only because of the smuggle-diffs-through-MCP approach. Do not recreate any of it:

  • ManagedFileWrite struct
  • decode_uri_component / encode_uri_component hand-rolled URI codec
  • try_extract_file_diff — parsing goose-internal://file-diff/ URIs from embedded resources
  • ToolCallContentWithDiffs — separating content from diffs
  • build_tool_call_content — intercepting tool results to extract diffs
  • Two-phase InProgress→write→Completed notification for diffs
  • create_file_diff_content — wrapping content in fake MCP resources
  • TextFileIOStrategy enum in goose-mcp
  • ReadStrategyFn, TextFileWriteResult types in goose-mcp
  • SpawnServerFn 4-parameter signature
  • add_extension_with_context on extension manager
  • spawn_developer custom function

Why: the server's job is transport. It should not parse custom URI schemes or process file diffs. The agent sends full content via fs/write_text_file; the editor computes the diff. Every editor listed above already does this.

Phase 5: Editors to test with

Don't just test with Zed. Test with multiple editors to validate spec compliance:

diffs: Vec<ManagedFileWrite>,
}

struct ToolPermissionRequestArgs {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit strange.. I think if an agent created this to avoid clippy warnings it is probably best to undo it. as fr as I can tell, we create this structure then destructure it immediately.

let supports_write = self.supports_fs_write();
let mut apply_failed = false;

if !diffs.is_empty() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is mixing abstraction concepts, the server code shouldn't be handling diffs, even if some agents aren't designed perfectly. I raised #7451 to suggest designing first before implementing. In there are some hints and source bases to explore.

The issue with doing things this way is that it is tech debt that takes a lot more attention to undo vs just do cleanly in the first place.

new_text: String,
}

fn decode_uri_component(value: &str) -> Option<String> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

server should not have the responsibility for being a URI encoder/decoder. look for existing crates or a utility, or in any case mention why custom decoding is an ACP concern required to implement this.

.is_some_and(|c| c.fs.read_text_file)
}

fn build_io_strategy(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a broken abstraction. The server's job is only to push the file capability to our dev extension which consumes it optionally as a trait. When that's missing, it uses the default implementation. What you can see happening here, is server inheriting a mix of concerns which ends up with having tight coupling and confusing code, where the core server handler is now also a diff machine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @codefromthecrypt , I think most of the complexity came from bridging ACP through the old goose-mcp developer path, which mixed transport and file-diff concerns across layers.

Now that developer is a platform extension after #7466, ACP can now inject capability-gated read/write delegates directly as you mentioned, keeping server transport-only and letting clients render diffs natively.

I still think developer_file_io delegate on PlatformExtenstionContext would be extension specific leakage.

I'll rebase the PR make it aligned with the new developer platform extension. If there are still concerns from your side around the design, feel free to take over.

Route developer write/edit through ACP fs read/write requests when
client capabilities are available, with local fs fallback as the
default behavior. Keep the server focused on capability-gated
transport wiring and verify delegation and permission behavior
with ACP integration tests.

Change-Id: I2ea8d50fd8c5ba2a3daa1f0c9148a321063820e9
Signed-off-by: rabi <ramishra@redhat.com>
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.

3 participants