acp
A client for the Agent Client Protocol: it starts a coding agent as a child process, holds a conversation with it, and keeps a model of that conversation a window can draw.
The editor holds no API key, knows no provider, and implements no tool-calling loop. All of that is the agent's own configuration, in a file this package does not read.
Layers
| File | What it is |
|---|---|
config.go |
acp.toml: the agents a project and a user list, merged, with the project's winning |
create.go |
The starter file a menu item writes |
framing.go |
Newline-delimited messages, as a jsonrpc.Framer |
protocol.go |
The wire types, and every method name in one place |
process.go |
The child: its pipes joined into one stream, its standard error drained |
trace.go |
TURBO_ACP_TRACE=<file>: every line in both directions, for "what did the agent actually send?" |
session.go |
The conversation: handshake, prompts, cancellation, permissions, the file methods |
transcript.go |
The conversation as a value — no terminal, no colours |
session_files.go |
The file half: fs/read_text_file, fs/write_text_file, and reading a mentioned file's text |
mention.go |
A file named with @, and the content blocks a prompt becomes — text, resource, resource_link |
render.go |
Entries into drawn lines: fences, wrapping, styles |
view.go view_draw.go view_events.go |
The widget: the conversation above, a box to type in below |
picker.go picker_draw.go |
The popup / and @ open in the box: the agent's commands, the project's files |
NewSession takes an io.ReadWriteCloser, not a command — the same seam lsp has, and for the same reason. The whole client is driven from a test against an agent in the same process, over net.Pipe: real framing, real concurrency, real decoding, no subprocess to reap and no model to reach. Start is the thin layer that builds that stream out of a child process's pipes.
Seven things that are easy to get wrong
The agent numbers its requests from one, and so do we. docker agent really does send id: 1 while a call of ours carrying the same id is in flight. The two directions are separate id spaces, which is jsonrpc's business — but it is why this client could not have been built on a connection that shared one map.
A permission cannot be answered where it arrives. session/request_permission reaches the reading goroutine, and the answer comes from a dialog somebody has to look at. Session records a *Permission and wakes the event loop; the dialog opens on the next turn and calls Answer. Answer and Cancel write on a goroutine of their own: an agent that has stopped reading must not be able to freeze the editor on the keystroke that answers it.
content is two different shapes under one name. A message chunk carries a single content block; a tool_call_update carries an array of them. It is kept as json.RawMessage and decoded per kind — decoding eagerly into either breaks on the other, and only once an agent uses a tool.
A reply arrives one token at a time. "I", " found", " agent", ".yaml". Transcript coalesces a run of chunks into one entry; an entry per chunk could be neither wrapped nor told apart from a fenced code block.
A command is a text prompt, not a method. available_commands_update tells the client what the agent answers to — /web, /compact — and the client sends the command as the text "/web agent client protocol", exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature.
A mention takes the name out of the text. @app/menus.go in the box becomes a content block in place — the file's text as a resource when the agent declared promptCapabilities.embeddedContext, a resource_link otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice.
Only a fence makes a block code. No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly.
Tests
go test ./acp/ drives the client against a fake agent written by hand — deliberately not built on this package's own types, because a peer that shared them could not catch the client encoding a field wrongly. The shapes it replays are copied from a recorded conversation with docker agent v1.139.0 against a local llama.cpp, not invented.
The drawing tests use a session whose agent never answers, so the conversation is whatever the test puts into it. A test that asserts on a screen while a live process writes to it passes or fails by luck, and one such test hid a real fault here for a whole session.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
|