Repository navigation
Add standalone Agent Client Protocol client layer - #24990
Conversation
9cad7d5 to
e30b885
Compare
A newer or vendor enum value (tool kind, tool status, option kind, stop reason) no longer fails the whole message: generated enums accept the known literals plus any other string, typed so callers can still narrow on the known ones. The generated header now records the pinned input digests, the generator digest and a body hash, so `verify:acp-protocol` catches a stale or hand-edited file without network access; it runs in lint and the PR workflow.
- Deliver notifications other than session/update through onExtensionNotification, in arrival order with session updates. - Accept _meta on prompt, setMode, setModel, setConfigOption and cancel. - cancel() always sends session/cancel once the session runs, since the agent can be in a turn it began itself; only a successful send is shared, so a failed write is retried. - Cancel aborts each open agent request's signal and lets its handler send its own answer; -32800 only when the handler rejects. - Permission requests validate only the session, tool call id and options; unreadable fields are dropped with a diagnostic, and any answer Orca cannot send is `cancelled` instead of a JSON-RPC error. Agent-started turns may ask; whether to show it is the caller's decision. - AcpAgentError marks the agent's own errors; AcpInvalidResponseError keeps the raw answer and validation issues for answers Orca could not read. - Lines over the size limit are classified by prefix (shared with the Codex reader): the owed request fails, an oversized agent request is answered with an error, and an unattributable response closes the connection.
A cancel that lands before a permission handler starts now still runs the permission path, so the agent gets the `cancelled` outcome rather than a request-cancelled error. A handler that ignores the abort no longer leaves the agent waiting: once the abort has run through, any request still unanswered gets request-cancelled. Handlers that answer on abort keep their own reply. Also renames a lint-rejected helper parameter, replaces a Reflect.apply in a test, and stops the permission diagnostic from firing with an empty list.
Removes the next-event-loop-turn fallback that answered request-cancelled for any handler still silent after a cancel. It raced answers that were still being saved (an approval mid-journal-write reached the agent as an error) and made the outcome depend on event-loop timing. The handler that owns an agent request now always sends its answer, or throws for request-cancelled; a request it never answers ends when the connection closes. A permission whose handler had not started still answers `cancelled`.
Review summary (head
|
The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle, then close the connection, which ends the agent. A steer used it too, so a slow agent lost its process just because the person added a message. requestSteerCancel() now sends session/cancel once per prompt, cancels the agent's open requests and answers later permissions cancelled, and never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel() stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel paths move into acp-prompt-cancel.ts over one cancel channel.
…caller owns Per review: a second steer before the first write lands returns that write instead of resolving early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that fails instead must not take the steer until the caller rebuilds the session; the Stop's says a prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives the runtime a handler that would allow: the open permission's signal aborts and the late one never reaches it.
|
Merged current main into this branch (clean, no conflicts) and added a separate cancel for steering (now
|
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 3 minutes. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configuration
⛔ Files ignored due to path filters (1)
📒 Files selected for processing (29)
📝 WalkthroughWalkthroughThe pull request adds ACP protocol schema generation and verification, a JSON-RPC peer over newline-delimited streams, and a session runtime for authentication, session operations, permissions, notifications, and cancellation. It also adds transport and runtime tests. The Codex app-server dispatcher now imports the JSON-RPC prefix classifier from the shared module. Priority: ⬇️ Low Merge Risk: 🔵 Low · up to The new ACP client is not yet used by any adapter, so users see no change today. In one narrow edge case, a failed Stop after a steer can show a permission prompt for a turn that is already ending. It is mergeable with a small follow-up fix. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The new client separates permission decisions from transport handling and includes resource limits and explicit cancellation controls. No currently reachable privileged operation or verified security weakness was established. Remaining uncertainty concerns how future callers enforce session identity, authorization, and recovery after failed session creation. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 34.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 29 functions across 22 files. (4 skipped: 4 unsupported.) ✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
e1706c5b-dd1e-46b1-8a7e-868f4796efda
⛔ Files ignored due to path filters (1)
src/main/acp/generated/acp-protocol.generated.tsis excluded by!**/*.generated.*,!**/generated/**
📒 Files selected for processing (27)
.gitattributes.github/workflows/pr.yml.oxlintrc.jsonconfig/scripts/acp/generate-protocol.mjsconfig/scripts/pr-preflight-gates.test.mjspackage.jsonsrc/main/acp/acp-errors.tssrc/main/acp/acp-incoming-requests.tssrc/main/acp/acp-json-rpc-peer.test.tssrc/main/acp/acp-json-rpc-peer.tssrc/main/acp/acp-oversized-lines.tssrc/main/acp/acp-peer-limits.tssrc/main/acp/acp-permission-requests.test.tssrc/main/acp/acp-permission-requests.tssrc/main/acp/acp-prompt-cancel.tssrc/main/acp/acp-scripted-agent.test-support.tssrc/main/acp/acp-session-agent-turns.test.tssrc/main/acp/acp-session-events.tssrc/main/acp/acp-session-lifecycle.test.tssrc/main/acp/acp-session-notifications.test.tssrc/main/acp/acp-session-runtime.test.tssrc/main/acp/acp-session-runtime.tssrc/main/acp/acp-session-setup.tssrc/main/acp/acp-stdio-error-boundary.tssrc/main/acp/acp-write-queue.tssrc/main/codex/codex-app-server-record-dispatch.tssrc/shared/json-rpc-record-prefix.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.
| await Promise.race([connection.send(), unconfirmed]).catch((error) => { | ||
| // Only a cancel that reached the agent is shared; a failed write is retried next call. | ||
| active.cancelPromise = undefined | ||
| active.cancelling = false | ||
| throw error | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep cancelling set when a steer cancel already exists and a Stop write fails.
Consider this sequence:
requestAcpSteerCancelsendssession/cancel. It setsactive.cancelling = trueand storesactive.steerCancel.- A later Stop calls
confirmAcpPromptCancel. - The Stop's
connection.send()rejects, for example with "ACP write queue capacity exceeded".
The catch block on Line 54 then resets active.cancelling to false. The steer's cancel was still sent to the agent, so the turn is still being cancelled. With cancelling === false, AcpSessionRuntime.handleRequest in src/main/acp/acp-session-runtime.ts stops answering new session/request_permission requests with cancelled. It forwards them to onPermission, so the user can see a permission prompt for a turn that is already ending.
requestAcpSteerCancel cannot repair the flag. When steerCancel is set, it returns the existing promise and does not touch cancelling. This conflicts with the stated rule "Only a cancel that reached the agent counts". Clear cancelling only when no steer cancel is outstanding.
🐛 Proposed fix
await Promise.race([connection.send(), unconfirmed]).catch((error) => {
// Only a cancel that reached the agent is shared; a failed write is retried next call.
active.cancelPromise = undefined
- active.cancelling = false
+ // A steer's cancel that was already sent still counts.
+ active.cancelling = active.steerCancel !== undefined
throw error
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| await Promise.race([connection.send(), unconfirmed]).catch((error) => { | |
| // Only a cancel that reached the agent is shared; a failed write is retried next call. | |
| active.cancelPromise = undefined | |
| active.cancelling = false | |
| throw error | |
| }) | |
| await Promise.race([connection.send(), unconfirmed]).catch((error) => { | |
| // Only a cancel that reached the agent is shared; a failed write is retried next call. | |
| active.cancelPromise = undefined | |
| // A steer's cancel that was already sent still counts. | |
| active.cancelling = active.steerCancel !== undefined | |
| throw error | |
| }) |
|
Merged current main and applied the same Electron-import check change #25225 carries: |
Main squash-merged D1 #24990 (from 320f347) and C1a #25141 (383b0dc). Conflicts: - #25720 dropped the launch-command override gate from native-chat create support and renamed the route input to startsOutsideWorkspaceRoot: main's version, with D3's agent-generic typing and host structured-agents input. - the model catalog store's refresh: D3's agent: string plus main's new lister parameter. - #25654's tool-run header restyle lands in C5's NativeChatToolRunCallCounts, which holds that span.
ELI5
Orca can drive only Claude and Codex as structured chats today. Other coding agents (Grok first) speak the Agent Client Protocol (ACP), and Orca has no reusable client for it. This PR adds that client as a standalone building block. No user-visible change: nothing calls it until a later adapter connects it to native chat.
Earlier review rounds found the first version would give up on a permission prompt after two minutes, end a turn after thirty minutes, pick a login method on its own, and drop messages containing a field value it did not recognize. Those are gone: decisions and turns wait until answered, cancelled or closed; the caller picks the login; unfamiliar values reach the caller.
This revision also moves into this PR the changes the Grok adapter branch (#25225) had been making to these files, so the client lands with the contract the rest of the stack actually uses. It fixes three problems found by review:
What Changed
An Electron-free module accepts the agent's input/output streams and speaks two-way JSON-RPC 2.0 over newline-delimited JSON: initialization, caller-chosen authentication, new/load/resume session, prompts, cancellation, and mode/model/configuration settings. File and terminal access are advertised as disabled. The caller owns the process and must call
close()when the child exits, even if a descendant keeps stdout open; closing the connection does not kill the process.session/update(an agent's own protocol extensions, such as Grok's "turn completed") are delivered throughonExtensionNotification, in arrival order with session updates.cancel()(a Stop) always sends the protocol's stop message once the session is running. If Orca's own prompt is running, it also waits up to 10 seconds (configurable) for that prompt to finish, then closes an unresponsive connection. A prompt that finishes in time leaves the agent running; the code that owns the process ends it on Stop. Permission requests during an agent-started turn reach the caller, which decides whether to show them.onRequesthandlers: the handler owns its request and must answer it or throw once the signal aborts; Orca never answers for a handler that is still running. A request whose handler never answers stays open until the session closes; with Orca's prompt running, the 10-second stop bound closes it. A permission whose handler had not started yet still answers "cancelled". Only a stop message that was actually written is reused by repeat calls; if writing it failed, the nextcancel()tries again.requestSteerCancel()sends the stop message once per prompt (a second call returns the first write), signals the agent's open requests, and answers later permission requests "cancelled". It never times out and never closes the connection: the prompt's own reply ends it, and the steer's prompt follows on the same connection. With no prompt of Orca's running it sends nothing. A Stop after a steer still waits its bounded 10 seconds and closes. If the prompt fails instead of ending, the caller must not send the steer into that session until it rebuilds it. Both cancels now live inacp-prompt-cancel.ts.AcpAgentErrormeans the agent itself answered with an error.AcpInvalidResponseErrormeans Orca could not read the agent's answer, and it keeps the raw answer and the validation problems. Callers can tell "the agent refused" from "Orca failed"._meta(the protocol's free-form metadata field) can be sent on prompt, set mode, set model, set configuration option and cancel.src/main/codex/tosrc/shared/json-rpc-record-prefix.ts, unchanged). The request owed that answer fails withAcpFrameTooLargeError; an oversized request from the agent gets an error answer; an oversized notification is logged. An oversized answer whose request cannot be identified closes the connection, because any pending call could be the one that never settles. Only the first 64 KB of a rejected line is inspected, so an oversized agent request that puts a hugeparamsbeforemethodcannot be identified and also closes the connection (recorded agents sendid,method,paramsin that order). Unlike the Codex reader, an oversized line that is not JSON-RPC at all (stray log output) is logged rather than closing the session.schema-v1.21.0unstable schema plus the older model API fromv0.11.6, with SHA-256-pinned downloads and the full Apache-2.0 license kept. The file header now records the input digests, the generator's own digest and a hash of the generated body, sopnpm run verify:acp-protocoldetects a stale or hand-edited file without network access. It runs inpnpm lintand as a step in the PR workflow.--check-onlinestill regenerates from the downloads and compares. (The offline check catches accidental edits; an edit that also rewrites the recorded body hash is caught only by--check-online.)Lines, queued writes and concurrent requests stay bounded, and writes honor backpressure. No dependency or lockfile change.
Why
The protocol layer should deliver what the agent sends and let the caller decide what to wait for, what to show and how to log in. A browser login cannot be assumed safe on a remote host, so authentication-required surfaces the advertised methods unless the caller names one, which is tried once.
Opening the enums in the generator fixes unfamiliar values everywhere at once. That includes the next layer, which re-reads the same updates, rather than patching each place that reads a message. A hand-written subset would avoid generation but lose the pinned provenance and make every new protocol field a hand edit. The permission-gating rule moved out because only the adapter knows when the agent has started a turn of its own.
cancel()lost its caller-asserted "the agent is in a turn" flag for the same reason: the stop message is session-wide in the protocol, and an agent with nothing running ignores it.A steer only asks the agent to wrap up its reply, so it gets the protocol's stop message and nothing else; ending the agent is a Stop's job. That is the common pattern's split: a steer sends the cancel notification with no timer and no kill, and a Stop waits briefly and then ends the agent for every ACP agent.
The bounded line framer and now the Codex reader's oversized-line classifier are reused. The Codex connection itself combines Codex-specific error handling with process ownership, so ACP gets its own small JSON-RPC peer for now (see the temporary item below).
Linked Issue
Internal foundational work; no pre-existing issue supplied.
Visual Proof
N/A — standalone protocol module with no UI or launch wiring.
Testing
What I verified:
acp-session-runtime.test.ts. A steer sends one stop message, never times out or closes even with a 100 ms Stop bound, aborts the open permission's handler, keeps a late permission from the handler, and the next prompt works on the same connection. A Stop after a steer still closes on its bound. A steer with no prompt running sends nothing. Each was checked by breaking the code: routing the steer through the Stop path, dropping its send-once guard, or skipping the open-request cancel each turns a test red.acp-permission-requests.test.tsandacp-session-agent-turns.test.ts. It also includes the 32 Codex connection tests that use the moved classifier._metaon every session call;verify:acp-protocolpasses offline, and catches a hand edit of the body and a changed generator.--check-onlinepasses.stream-json/stream-chainmodules from a stale local install, in files this PR does not touch.oxlint, the anti-slop pass (config/oxlint-anti-slop.json, which the previous revision failed), the focused code-quality plugins, type-aware code quality on changed files, the changed-lines gate, the reliability-gate manifest, the max-lines and ts-nocheck ratchets, the Node runtime pin, README links,verify:acp-protocoland formatting.What I didn't verify: no real agent CLI, Electron app, Linux/Windows run, SSH session or packaged app. CI on this revision is the authority for typecheck and static analysis. The cross-version
qoder-history-search-downgradetest fails on unrelated PRs too and is not part of this PR's acceptance.AI Disclosure
Review
Differences from the common pattern
session/updateare delivered throughonExtensionNotification; the common pattern drops them. They carry the only end signal of a turn the agent starts on its own.cancel()closes the connection only when the wait runs out; ending the agent on every Stop is left to the process owner (the adapter), which the common pattern does inside its connection. Same reason as the first item: the code that starts the process confirms it exited.$/cancel_request(the agent withdrawing one of its own requests) is not implemented, as in the common pattern. Stopping or closing the session already clears open requests; a follow-up adds it before any caller relies on it.Agent skill upstream boundary
Notes
Paths, credentials and streams belong to the caller's execution host. No local-machine or Git-worktree assumption, Electron import, Orca remote RPC change, or new advertised runtime capability is introduced. The Grok adapter branch (#25225) and the translation branch (#25090) build on this; their owners pick these changes up on their next merge. The PR remains draft.
Checklist
88490e58cf3(job); the test and packaging jobs were still running when this was written