Repository navigation
Conversation
…ject is the authorization boundary
AcpChannel serves a Channels agent to Agent Client Protocol clients such as Zed and T3 Code. Each ACP session is a conversation; prompts, tool calls, approvals and cancellation map onto the harness's turns. acp() is its side in the gateway, like web(). The gateway now names the Channel that took an upgrade in the connection identity, so the agent's channel mounted under the same key serves the connection. `agents acp <url>` bridges an ACP client's stdio to the channel's WebSocket. The Channels example mounts the channel on both agents at /acp/<harness>/<room> and ships bin/acp-agent for local clients.
🦋 Changeset detectedLatest commit: 0ca868d The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🟢 agents import sizes: 1 entry point changed, no growth
Changed exports (2)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
| case "session/resume": { | ||
| const sessionId = await this.#existing(params); | ||
| if (sessionId === undefined) return fail(...notFound()); | ||
| this.#remember(sessionId, params); | ||
| this.#follow(connection, identity, sessionId); | ||
| reply({}); |
There was a problem hiding this comment.
🔴 Resumed sessions miss running output
When a client resumes during a running turn, session/resume follows the session without reading its active response. The client misses subsequent text and tool updates because #read only starts on a running-turn update or load.
Learn more
A running turn's response is persisted as a stream. #onTurn starts forwarding when the turn first becomes running, and #replay restarts forwarding for loaded sessions, but resume only follows. A reconnecting ACP client therefore joins after the running-turn event and receives none of that stream's output. The prompt itself can still finish on the previous connection, leaving the new client's view incomplete.
Example: Client A prompts session s1, disconnects during a long answer, and reconnects with session/resume. The remaining answer chunks reach no reader on the new connection, although session/load on the same session starts reading them.
Recommended fix: On resume, inspect the current snapshot's running turns and start response reads as #replay does, without replaying saved transcript messages if resume semantics forbid it. Ensure an in-progress read is not duplicated on repeated resume calls.
Was this helpful? React with 👍 or 👎 to provide feedback.
| .then( | ||
| () => {}, | ||
| (error: unknown) => { | ||
| if (!controller.signal.aborted) { | ||
| console.error(`Failed to read response "${responseId}"`, error); | ||
| } | ||
| } | ||
| ) | ||
| .finally(() => { | ||
| reads.delete(responseId); | ||
| ended.add(responseId); | ||
| }); |
There was a problem hiding this comment.
🟡 Interrupted answers report successful completion
When readResponse fails or returns interrupted, #read resolves done normally. #settled then sends end_turn, despite missing answer chunks.
Learn more
The Channels response reader returns an ending of ended, interrupted, or not-found, and it can reject. The handler ignores its ending and catches rejection, making done fulfill in all cases. #settled waits for done before answering the prompt with end_turn for a completed turn. A failed stream thus looks successful to the ACP client, even if text or tool updates are missing.
Example: A stream read throws halfway through an otherwise completed turn. The client gets half an answer followed by {stopReason: "end_turn"} rather than an error indicating incomplete output.
Recommended fix: Preserve the readResponse ending or rejection in Read.done; make #settled answer the affected prompt with an error when output delivery was interrupted or missing, while still handling aborted reads caused by disconnection.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const recorded = this.#record(id).cwd; | ||
| // A session opened from elsewhere is not one of this directory's. | ||
| if (cwd !== undefined && recorded !== undefined && recorded !== cwd) { | ||
| continue; | ||
| } | ||
| // ACP requires a directory; sessions no client opened have none. | ||
| sessions.push({ sessionId: id, cwd: recorded ?? cwd ?? "/" }); |
There was a problem hiding this comment.
🟡 Directory listings misattribute unrecorded sessions
When session/list specifies cwd, #list includes sessions without a recorded directory and labels them with that cwd. The same default or Web-created session appears in every project.
Learn more
Channels lists every conversation in the agent object, including the default conversation and those created through Web. Only ACP-created or opened sessions have a stored cwd. With a directory filter, unrecorded conversations bypass the mismatch condition and inherit the filter value in the returned object. Clients querying separate project directories both receive the same session under their own directory.
Example: The agent has default session root with no cwd. session/list({cwd: "/alpha"}) reports root at /alpha; session/list({cwd: "/beta"}) reports root at /beta.
Recommended fix: Choose one stable directory for unrecorded conversations, or exclude them from filtered lists. Do not populate an unrecorded session's cwd from the query filter.
Was this helpful? React with 👍 or 👎 to provide feedback.
| input.on("close", () => { | ||
| stdinClosed = true; | ||
| if (socket.readyState === WebSocket.OPEN) { | ||
| socket.close(1000, "stdin closed"); | ||
| } else if (socket.readyState !== WebSocket.CONNECTING) { |
There was a problem hiding this comment.
🟡 Input EOF drops pending ACP replies
When a client ends stdin after sending requests, bridge closes the socket before their replies arrive. The client loses outstanding responses, including session/prompt results.
Learn more
ACP JSON-RPC requests can finish long after the bridge has forwarded them, especially session/prompt. Stdin EOF means no more client requests, but this handler immediately initiates the WebSocket close handshake. Outstanding agent responses cannot be forwarded to stdout after the socket closes. The open handler also flushes queued requests and immediately closes the socket if stdin ended during connection setup.
Example: A process pipes one session/prompt line into agents acp, closes stdin, and waits for stdout. The bridge closes its socket while the agent is generating, so it never forwards the stop reason.
Recommended fix: Track forwarded request IDs and outstanding responses, and defer socket closure after stdin EOF until they settle or a bounded shutdown policy applies. Apply the same rule when flushing requests queued before WebSocket open.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
d435f12 to
277afd8
Compare
| if (!asking) { | ||
| this.#turnResponses.delete(turnKey(conversationId, turn.turnId)); | ||
| this.#answer(conversationId, turn.turnId, (prompt) => | ||
| result(prompt.rpcId, { stopReason: "end_turn" }) | ||
| ); |
There was a problem hiding this comment.
🔴 Client-tool prompts strand the conversation
When an ACP prompt requests a client tool, #askPermissions ends the prompt without returning a tool result. The AI SDK harness keeps the call unanswered, so later prompts cannot produce a normal response.
Learn more
A client tool is a tool with no server-side execute function; the example's getLocation uses one here. The harness stops rather than generating another model response while its last assistant message awaits tool input. #askPermissions handles only approval-requested parts, so an input-available client tool makes asking false and reports end_turn while leaving the harness waiting. Subsequent prompts encounter the same unanswered tool.
Example: An ACP user asks "Where am I?" and the agent calls getLocation. ACP receives a pending tool call and a successful prompt response, but no way to supply its result. A follow-up question also ends without a useful model response.
Recommended fix: Either implement the ACP client-tool request/result flow and dispatch a tool-result for the original turn, or reject/resolve unsupported client tools before claiming that the turn completed. Test a follow-up prompt after a client-tool turn against AiSdkHarness.
Was this helpful? React with 👍 or 👎 to provide feedback.
| #unfollow(connection: Connection, sessionId: string): void { | ||
| const identity = this.#identityOf(connection); | ||
| if (!identity) return; | ||
| identity.sessions = identity.sessions.filter((id) => id !== sessionId); | ||
| setConnectionFlag(connection, IDENTITY_KEY, identity); | ||
| } |
There was a problem hiding this comment.
🟡 Closed sessions keep streaming updates
When session/close unfollows a running session, #unfollow leaves its response reader active. Its onChunks callback keeps sending updates for the closed session.
Learn more
Each running turn starts a response reader in #read. Its chunk callback sends updates directly to the socket without consulting the connection's followed sessions. session/close calls #unfollow, but only socket close calls #stopReads, so the reader continues until its response ends.
Example: A client starts a slow prompt on session s1, calls session/close for s1, and receives a successful close reply. Model tokens generated afterwards still arrive as session/update for s1.
Recommended fix: Track the conversation for each read and abort its reader when #unfollow removes the session. Ensure cancelling a prompt still returns its expected prompt response before detaching if required by ACP.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (prompts.length === 0) { | ||
| if (turn.outcome !== "awaiting-input") this.#turnResponses.delete(key); | ||
| return; |
There was a problem hiding this comment.
🟡 Disconnected approvals retain response state
When a client disconnects during an awaiting-input turn, #close removes its prompt. Later #settled keeps that turn's response entry forever because no prompts remain.
Learn more
#turnResponses is an in-memory map populated whenever a turn starts running. On disconnect, #close removes the connection's pending prompts from session state. An awaiting-input turn can then settle without a pending prompt; this branch retains its entry permanently. The map grows with every such turn until the object is evicted.
Example: Ten clients each submit a prompt that calls an approval tool and disconnect before answering. The turns settle awaiting input, but all ten response IDs remain in the live object's map.
Recommended fix: Delete the response mapping when no prompt remains, including for awaiting-input turns. No later continuation needs that mapping without an outstanding prompt.
| if (prompts.length === 0) { | |
| if (turn.outcome !== "awaiting-input") this.#turnResponses.delete(key); | |
| return; | |
| if (prompts.length === 0) { | |
| this.#turnResponses.delete(key); | |
| return; | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| ## Gateway and agent | ||
|
|
||
| Webhooks and WebSocket upgrades both go through the `ChannelGateway` in the Worker, which routes each to a conversation's agent. `publish` pushes turn statuses and transcript updates to each channel; the channel reads response output from Streams itself. | ||
| Webhooks and WebSocket upgrades both go through the `ChannelGateway` in the Worker, which routes each to the agent object a route names. That object holds any number of conversations. `publish` pushes turn statuses and transcript updates to each channel; the channel reads response output from Streams itself. |
There was a problem hiding this comment.
| let stdinClosed = false; | ||
|
|
||
| socket.addEventListener("open", () => { | ||
| log(`connected to ${url}`); |
| url: string, | ||
| init: { headers: Record<string, string> } | ||
| ) => WebSocket; | ||
| const socket = new NodeWebSocket(url, { headers }); |
This PR adds
AcpChannel, which serves a Channels agent to Agent Client Protocol clients such as T3 Code and Zed, andnpx agents acp <url>, the stdio bridge those clients launch. It stacks on #2497 and uses its per-Channelupgrade.Why
ChannelsHostand the live updates other surfaces rely on.Public API Surface
Added, all additive and all experimental:
AcpChannel,AcpChannelOptionsagents/experimental/channels/acpagentInfois whatinitializereportsacp(),AcpIngressOptionsagents/experimental/channels/acpweb().matchdefaults to/acp. ACP upgrades name no conversationAgentInfoImplementationinfoWebIdentity.channelagents acp <url> [--as] [--header]agents tuiChanged:
channelon every upgrade it forwards. AWebChannelignores connections whosechannelnames a different key. Headers withoutchannelstill go to the Web Channel.Code Changes
packages/agentsacp/protocol.tsis a hand-written subset of ACP v1 plus JSON-RPC helpers. The package gains no dependency on the ACP SDK.acp/channel.tsmaps ACP ontoChannelsHost:session/new,load,resume,list,closeandforkbecome conversations.loadreplays the transcript as session updates.session/promptdispatches a message and answers with a stop reason once the turn settles: completed isend_turn, aborted iscancelled, and failed is a JSON-RPC error.session/request_permission, and the answer is dispatched as an approval response.session/cancelcancels the running turn.user_message_chunk, so the ACP client shows the whole conversation.host.state, keyed by session, so they survive the agent being evicted. Turn work runs through a queue per conversation, so a prompt is answered only after its response has been forwarded.mcpServersand ignores them, because the agent runs in a Worker rather than beside the client.acp/bridge.tsreuses the TUI's argument parsing and Access handling. It writes only JSON-RPC to stdout, which drops the agent's identity frame that ACP clients would reject, and it logs to stderr.gateway.tsaddschannel: channelKeyto the identity in#forwardUpgrade.web/channel.tsskips connections addressed to another key.examples/next/channelsacp: new AcpChannel(...). The Worker addsacp()for/acp/<harness>/<room>with the same demo-only?as=participant and open rooms as the web side.bin/acp-agentrunsagents acpagainst the dev server and ignores its arguments, because T3 Code's executable override passes the registry agent's arguments. It is configured withACP_HARNESS,ACP_ROOM,ACP_PARTICIPANTorACP_URL.wrangler.jsoncadds/acp/*torun_worker_first.Known limitations, left for follow-ups:
session/newon every refresh, so empty sessions pile up.