acp-sdk 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +141 -69
  4. data/lib/acp/agent_connection/client.rb +2 -3
  5. data/lib/acp/agent_connection/optional_route.rb +45 -0
  6. data/lib/acp/agent_connection.rb +120 -76
  7. data/lib/acp/agent_process.rb +4 -4
  8. data/lib/acp/client_connection/elicitations.rb +44 -0
  9. data/lib/acp/client_connection/inbound/route.rb +29 -0
  10. data/lib/acp/client_connection/inbound.rb +232 -0
  11. data/lib/acp/client_connection/streams.rb +20 -52
  12. data/lib/acp/client_connection.rb +34 -309
  13. data/lib/acp/extensions.rb +6 -0
  14. data/lib/acp/protocol.rb +14 -4
  15. data/lib/acp/transport/reply.rb +2 -2
  16. data/lib/acp/transport/stdio.rb +2 -2
  17. data/lib/acp/types/parse_error.rb +1 -1
  18. data/lib/acp/types.rb +13 -0
  19. data/lib/acp/version.rb +1 -1
  20. data/sig/generated/acp/agent_connection/optional_route.rbs +35 -0
  21. data/sig/generated/acp/agent_connection.rbs +5 -21
  22. data/sig/generated/acp/client_connection/elicitations.rbs +29 -0
  23. data/sig/generated/acp/client_connection/inbound/route.rbs +26 -0
  24. data/sig/generated/acp/client_connection/inbound.rbs +90 -0
  25. data/sig/generated/acp/client_connection/streams.rbs +7 -28
  26. data/sig/generated/acp/client_connection.rbs +12 -136
  27. data/sig/generated/acp/extensions.rbs +4 -0
  28. data/sig/generated/acp/protocol.rbs +8 -0
  29. data/sig/generated/acp/transport/reply.rbs +3 -3
  30. data/sig/generated/acp/transport/stdio.rbs +4 -4
  31. data/sig/generated/acp/types.rbs +9 -0
  32. data/sig/manual/acp/agent_connection.rbs +5 -57
  33. data/sig/manual/acp/client_connection.rbs +27 -0
  34. data/sig/manual/acp/transport/stdio.rbs +1 -5
  35. metadata +9 -5
  36. data/lib/acp/agent_connection/optional_method/spec.rb +0 -42
  37. data/lib/acp/agent_connection/optional_method.rb +0 -40
  38. data/sig/generated/acp/agent_connection/optional_method/spec.rbs +0 -32
  39. data/sig/generated/acp/agent_connection/optional_method.rbs +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdea95657cb02b7563bbc7730fe4b346cb7e80f08ab78071b0da79e3d46704e3
4
- data.tar.gz: 57efa048c5f5959d842e89d750ea2bd92e6caf4e4d6fc0c54964f2eaca577776
3
+ metadata.gz: baf3230fbed41f9fac37c739b21bfe649fb83566b36d22930fb410a5904fa059
4
+ data.tar.gz: 4a1ff54c9f5d5af5534d37fcd2b2886a0624a8ee582e00392e0952634c3cda56
5
5
  SHA512:
6
- metadata.gz: d4120fff675aa4c1afa4c9101b5403997f6a281c1eab85c34b2515958e4ee87fd0a13e1083015a66a0549a88e1b18d7aa264b948277a6650ad4062fffef6d046
7
- data.tar.gz: 3cba7edd6dccf7300211347d17114550588cd1ce7ba581f4da53f4d7623beffe923fd1912dd9a7c42e698d823f2dfad4a9344920a5962c3e9e77c39467d51409
6
+ metadata.gz: 4b8547d48ebfdf296f218e1a2c7f1bb9d727bf86b5f02de6b7b2e5230a175d408783dafcde63677c800344bed52003c2ecc58b830a134902daaa600341b9875a
7
+ data.tar.gz: b85b63b6ccccfaab69409933376f7d5e691c0e7619227f92861c417f7fd2517b172a271224225ee40eb1f88c9cba3f55e4dd0c1685e8c9a9663bd1432daec0c3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0](https://github.com/bottrall/acp-sdk/compare/v0.13.0...v0.14.0) (2026-10-24)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * build a client with ACP::ClientConnection.build instead of ClientConnection.new, which now takes only its collaborators (transport:, streams:, inbound:). The seven fs and terminal handler keywords become two handler objects, fs: and terminal:, and the elicitation: and complete_elicitation: callables become one elicitation: object responding to create_elicitation and optionally complete_elicitation. The unserved-capability ArgumentError from connect now names handler methods (write_text_file) rather than capability keys (fs.writeTextFile).
9
+
10
+ ### Features
11
+
12
+ * split ClientConnection into outbound, inbound and elicitations ([#159](https://github.com/bottrall/acp-sdk/issues/159)) ([3bdd4b1](https://github.com/bottrall/acp-sdk/commit/3bdd4b17f5f3b8aaf95c48d0e39b68710ab04cce))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * reject non-object payloads through one shared parse ([#156](https://github.com/bottrall/acp-sdk/issues/156)) ([930ea26](https://github.com/bottrall/acp-sdk/commit/930ea26d12c67bf72d6638f836eb790c91f05642))
18
+
3
19
  ## [0.13.0](https://github.com/bottrall/acp-sdk/compare/v0.12.0...v0.13.0) (2026-10-13)
4
20
 
5
21
 
data/README.md CHANGED
@@ -1,90 +1,117 @@
1
1
  # acp-sdk
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/acp-sdk.svg)](https://rubygems.org/gems/acp-sdk)
4
+ [![CI](https://github.com/bottrall/acp-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/bottrall/acp-sdk/actions/workflows/ci.yml)
4
5
 
5
- A Ruby SDK for the [Agent Client Protocol](https://agentclientprotocol.com) (ACP). It covers both sides of the protocol:
6
+ A Ruby SDK for the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), the protocol editors such as Zed and JetBrains use to talk to coding agents. Use it to:
6
7
 
7
- - **Agent side** (`ACP::AgentConnection`): expose your agent to ACP clients such as Zed and JetBrains.
8
- - **Client side** (`ACP::ClientConnection`): drive ACP agents from Ruby, for an editor integration or for tests.
8
+ - **Build an agent** that any ACP client can run.
9
+ - **Drive an agent** from Ruby, for an editor integration, a script or a test suite.
9
10
 
10
- Every ACP request, response and notification is a generated Ruby class under `ACP::Types`, and messages travel as JSON-RPC over stdio. The SDK speaks ACP protocol version 1.
11
+ Every ACP message is a typed Ruby object, and the SDK takes care of the wire format, capability negotiation and cancellation. It speaks ACP protocol version 1.
11
12
 
12
13
  ## Installation
13
14
 
14
- Requires Ruby 3.4 or later (CI tests 3.4 and 4.0).
15
-
16
- Add the gem to your Gemfile:
15
+ Requires Ruby 3.4 or later.
17
16
 
18
17
  ```sh
19
18
  bundle add acp-sdk
20
19
  ```
21
20
 
22
- Or install it directly:
23
-
24
- ```sh
25
- gem install acp-sdk
26
- ```
27
-
28
- Then require it:
29
-
30
21
  ```ruby
31
22
  require 'acp/sdk'
32
23
  ```
33
24
 
34
- ## Serving an agent
25
+ ## Building an agent
35
26
 
36
- An agent is a plain Ruby object. `ACP::AgentConnection` answers `initialize` itself, turns each request into its generated `ACP::Types` object, calls the agent, and sends back what it returns. The factory block receives an `ACP::AgentConnection::Client` handle, which the agent keeps for sending session updates, asking permission, reading and writing files, and running commands in terminals.
27
+ An agent is a plain Ruby object. `ACP::AgentConnection` calls its methods with typed requests and sends back what they return. The block receives a `client` handle for talking back to the editor.
37
28
 
38
29
  ```ruby
39
30
  require 'acp/sdk'
31
+ require 'securerandom'
32
+
33
+ class MyAgent
34
+ def initialize(client:)
35
+ @client = client
36
+ end
37
+
38
+ def new_session(_request)
39
+ ACP::Types::NewSessionResponse.new(session_id: SecureRandom.uuid)
40
+ end
41
+
42
+ def prompt(request)
43
+ reply = ACP::Types::ContentBlock::Text.new(text: 'Hello from Ruby!')
44
+ @client.update(request.session_id, ACP::Types::SessionUpdate::AgentMessageChunk.new(content: reply))
45
+ ACP::Types::PromptResponse.new(stop_reason: ACP::Types::StopReason::END_TURN)
46
+ end
47
+
48
+ def cancel(_notification); end
49
+ end
40
50
 
41
- connection = ACP::AgentConnection.new(
51
+ ACP::AgentConnection.new(
42
52
  transport: ACP::Transport::Stdio.new(input: $stdin, output: $stdout),
43
- capabilities: ACP::Types::AgentCapabilities.new(load_session: true),
53
+ capabilities: ACP::Types::AgentCapabilities.new,
44
54
  agent_info: ACP::Types::Implementation.new(name: 'my-agent', version: '1.0.0')
45
- ) { |client| MyAgent.new(client:) }
46
- connection.start.join
55
+ ) { |client| MyAgent.new(client:) }.start.join
47
56
  ```
48
57
 
49
- The agent's contract:
58
+ Point your editor at that script and you have a working agent. For a fuller one that streams updates, asks permission, handles cancellation, uses files and terminals and persists sessions, see [`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb).
50
59
 
51
- - It defines `new_session`, `prompt` and `cancel`. `load_session`, `list_sessions`, `resume_session`, `close_session` and `delete_session` are required only when `capabilities` advertises `load_session` or `session_capabilities.list`, `.resume`, `.close` or `.delete`, and `authenticate` only when `auth_methods` is non-empty, and `logout` only when `capabilities` advertises `auth.logout`; `start` raises `ArgumentError` when one is advertised but missing, and a method that is not advertised is answered with `-32601`. `change_session_mode` and `change_session_config_option` are optional, since modes and config options are offered per session rather than in `initialize`; `session/set_mode` and `session/set_config_option` are answered with `-32601` when the agent does not define them. `session_created`, if defined, runs right after the `session/new` reply is sent. `fork_session` is the same shape but unstable: `session/fork` routes only when the caller has opted into the unstable types with `require 'acp/types/unstable'` and `capabilities` advertises `session_capabilities.fork`, and is answered with `-32601` otherwise. `list_providers`, `set_provider` and `disable_provider` are the same shape but unstable too: `providers/list`, `providers/set` and `providers/disable` route only when the caller has opted into the unstable types with `require 'acp/types/unstable'` and `capabilities` advertises `providers`, and are answered with `-32601` otherwise.
52
- - Each request method returns its response type or an `ACP::RequestError`, which is sent as the error reply. Params that do not match the schema are answered with `-32602` before the agent sees them, with `data` naming the failing path, e.g. `{ errors: ['sessionId: expected String, got Integer'] }`.
53
- - A handler that raises is answered with `-32603` and the message `Internal error`. Nothing from the exception reaches the peer — its class and message are logged to the transport's logger instead, since messages can interpolate paths, queries or credentials. Return an `ACP::RequestError` from the handler when the peer should see a real error message.
54
- - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it, and call `client.cancel_requests` to cancel the connection's outstanding requests to the client — a blocked `request_permission` or `wait_for_terminal_exit`, say. That sends `$/cancel_request` for each one, and the blocked call returns once the client answers it, with its response or the `-32800` error.
55
- - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
56
- - `client.cancelled?` is true in a handler when the client cancelled the request it is serving with `$/cancel_request`. The handler can end early by returning `ACP::RequestError.request_cancelled`, which is answered as `-32800`, or return a normal response, which is sent as-is — the transport sends exactly one response either way.
57
- - `client.capabilities` is `nil` until the client sends `initialize`.
58
- - `client.read_text_file` and `client.write_text_file` take an `ACP::Types::ReadTextFileRequest` or `ACP::Types::WriteTextFileRequest` and return the response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `fs.read_text_file` or `fs.write_text_file`, they return `-32601` without sending anything to the client.
59
- - `client.create_terminal`, `client.terminal_output`, `client.wait_for_terminal_exit`, `client.kill_terminal` and `client.release_terminal` take the matching `ACP::Types` request (`CreateTerminalRequest`, `TerminalOutputRequest`, `WaitForTerminalExitRequest`, `KillTerminalRequest`, `ReleaseTerminalRequest`) and return its response type or an `ACP::RequestError`. Unless `client.capabilities` advertises `terminal`, they return `-32601` without sending anything to the client. The agent must release every terminal it creates.
60
- - `client.create_elicitation` takes an `ACP::Types::CreateElicitationRequest` and returns an `ACP::Types::CreateElicitationResponse` or an `ACP::RequestError`. Unless `client.capabilities` advertises the `elicitation.form` or `elicitation.url` mode the request asks for, it returns `-32601` without sending anything to the client. A response action the SDK does not know comes through as the raw hash the client sent. `client.complete_elicitation` takes an `ACP::Types::CompleteElicitationNotification` and sends nothing, returning `-32601`, unless the client advertises `elicitation.url`, since only URL-mode elicitations are completed.
61
- - Once the transport's input ends, a `client` request still waiting for its answer and every one made afterwards return an `ACP::RequestError` whose `connection_closed?` is true. It is false for every error the client sent, even one with the same `-32603` code and `Connection closed` message, so a handler can tell a client that is gone from one that refused without matching the message.
60
+ ### Agent methods
62
61
 
63
- Extensions are `_`-prefixed methods outside the spec. `AgentConnection` takes `extension_requests` and `extension_notifications` hashes keyed by the raw wire name, and `client.ext_request` and `client.ext_notify` send them to the client; a handler name without the `_` prefix raises `ArgumentError`, and an extension request with no handler is answered with `-32601`.
62
+ Each request method returns its response type, or an [`ACP::RequestError`](#errors) to reject the request.
64
63
 
65
- An embedder that manages the connection's lifecycle keeps a reference to the transport it built: `transport.serving_ids` returns the ids of the requests whose handlers are still running, and `transport.drain` blocks, with no timeout, until those handlers have replied and any `session_created` hook has run. `drain` waits only for the requests in flight when it is called, so join the reader thread `start` returns first — once the client hangs up nothing new arrives — and close the IOs after it returns. A write after either end of the output has closed is logged to the transport's logger as a warning and dropped rather than raised.
64
+ | Method | Implement it when |
65
+ | --- | --- |
66
+ | `new_session`, `prompt`, `cancel` | Always |
67
+ | `load_session` | `capabilities` sets `load_session` |
68
+ | `list_sessions`, `resume_session`, `close_session`, `delete_session` | `capabilities.session_capabilities` sets `list`, `resume`, `close` or `delete` |
69
+ | `authenticate` | You pass `auth_methods:` |
70
+ | `logout` | `capabilities.auth` sets `logout` |
71
+ | `change_session_mode`, `change_session_config_option` | Your sessions offer modes or config options |
72
+ | `session_created(response)` | You want a hook after `session/new` is answered, e.g. to send available commands |
66
73
 
67
- [`examples/echo_agent.rb`](https://github.com/bottrall/acp-sdk/blob/main/examples/echo_agent.rb) is a complete agent that streams updates, asks permission, handles cancellation, reads and writes files and runs commands through the client (`/read <path>`, `/write <path> <text>` and `/run <command> [args]`, each advertised as a slash command only when the client supports it), and supports `session/load`, `session/list`, `session/resume`, `session/close` and `session/delete`.
74
+ `start` raises `ArgumentError` if you advertise a capability without implementing its method. Requests for anything you don't advertise are rejected before they reach your agent.
68
75
 
69
- ## Driving an agent
76
+ ### Calling the client
70
77
 
71
- `ACP::ClientConnection` is the other side of the same transport. Each method takes the request's generated `ACP::Types` object and returns its response type or the agent's `ACP::RequestError`. `connect` sends `initialize`, since Ruby reserves that name for the constructor, and `authenticate` and `logout` send their namesake methods. `set_session_mode` sends `session/set_mode`, and `set_session_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply. `fork_session` sends `session/fork`; it is unstable, so the request and response types exist only under `ACP::Types::Unstable` and calling it before `require 'acp/types/unstable'` raises `NameError`. `list_providers`, `set_provider` and `disable_provider` send `providers/list`, `providers/set` and `providers/disable` on the same unstable terms. A handler that raises is answered with `-32603` and the message `Internal error`, and the exception is only logged to the transport's logger.
78
+ | `client.` | Does |
79
+ | --- | --- |
80
+ | `update(session_id, update)` | Streams a `SessionUpdate`: message chunks, tool calls, plans and so on |
81
+ | `available_commands(session_id, commands)` | Advertises slash commands |
82
+ | `request_permission(request)` | Asks the user to approve a tool call |
83
+ | `read_text_file`, `write_text_file` | Reads and writes files through the editor |
84
+ | `create_terminal`, `terminal_output`, `wait_for_terminal_exit`, `kill_terminal`, `release_terminal` | Runs commands in the editor. Release every terminal you create. |
85
+ | `create_elicitation`, `complete_elicitation` | Asks the user for structured input |
86
+ | `capabilities` | The client's `ClientCapabilities` (`nil` until the client connects) |
72
87
 
73
- `ACP::AgentProcess.spawn` starts a command with args, env and cwd, wires its stdio to a new `ACP::ClientConnection` and starts it. The block receives the connection and the process handle (`pid`, plus `stderr` when captured), and the child is terminated — after its stdin is closed, with TERM and then KILL if it will not exit — when the block exits, so a raised block cannot leak the process. Without a block, `spawn` returns the process and calling `terminate` is yours. `stderr` is `:inherit` by default, so the child writes to your stderr; pass `:capture` to collect it on the process instead.
88
+ File, terminal and elicitation calls need the matching client capability. Without it they return an error without contacting the client, so check `client.capabilities` before offering a feature that depends on one.
74
89
 
75
- ```ruby
76
- require 'acp/sdk'
90
+ ### Cancellation
91
+
92
+ `cancel` must return quickly, so record the cancellation and return. Call `client.cancel_requests` to unblock anything your agent is waiting on (a permission prompt, a terminal), then have `prompt` return `StopReason::CANCELLED`. The SDK doesn't end the turn for you.
93
+
94
+ Inside a request handler, `client.cancelled?` tells you whether the client has cancelled that request. Return `ACP::RequestError.request_cancelled` to stop early.
95
+
96
+ ### Shutting down
77
97
 
78
- response = ACP::AgentProcess.spawn(
98
+ `start` returns a thread that finishes when the client disconnects. To wait for in-flight handlers before closing your IO, keep a reference to the transport and call `transport.drain` after joining that thread.
99
+
100
+ ## Driving an agent
101
+
102
+ `ACP::AgentProcess.spawn` launches an agent as a child process and connects to it. The process is terminated when the block exits, even if the block raises.
103
+
104
+ ```ruby
105
+ ACP::AgentProcess.spawn(
79
106
  'my-agent', '--flag',
80
107
  env: { 'API_KEY' => '...' },
81
108
  cwd: Dir.pwd,
82
- stderr: :capture,
83
109
  permission: ->(request) { ask_the_user(request) },
84
110
  updates: ->(notification) { show(notification) }
85
111
  ) do |connection|
86
112
  connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
87
113
  session = connection.new_session(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
114
+
88
115
  prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
89
116
  connection.prompt_session(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
90
117
  print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
@@ -92,52 +119,97 @@ response = ACP::AgentProcess.spawn(
92
119
  end
93
120
  ```
94
121
 
95
- Any transport over a pair of IOs works too, so the connection can still be wired by hand when the agent is not a child process:
122
+ - Connection methods mirror the protocol: `new_session`, `prompt_session`, `cancel_session`, `set_session_mode` and so on. `connect` sends `initialize`, because Ruby reserves that name.
123
+ - `prompt_session` and `load_session` yield the session's updates as they arrive. Updates sent at other times go to `updates:`.
124
+ - Without a block, `spawn` returns the process and you call `terminate` yourself. Pass `stderr: :capture` to collect the agent's stderr on `process.stderr`.
125
+
126
+ ### Serving files, terminals and elicitation
127
+
128
+ To let the agent use files, terminals or elicitation, build an `ACP::ClientConnection` with a handler object for each capability and advertise the capability when you connect:
96
129
 
97
130
  ```ruby
98
- connection = ACP::ClientConnection.new(
99
- transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
100
- permission: ->(request) { ask_the_user(request) },
101
- read_text_file: ->(request) { ACP::Types::ReadTextFileResponse.new(content: File.read(request.path)) },
102
- write_text_file: ->(request) {
131
+ class Files
132
+ def read_text_file(request)
133
+ ACP::Types::ReadTextFileResponse.new(content: File.read(request.path))
134
+ end
135
+
136
+ def write_text_file(request)
103
137
  File.write(request.path, request.content)
104
138
  ACP::Types::WriteTextFileResponse.new
105
- },
106
- updates: ->(notification) { show(notification) }
139
+ end
140
+ end
141
+
142
+ connection = ACP::ClientConnection.build(
143
+ transport: ACP::Transport::Stdio.new(input: agent_stdout, output: agent_stdin),
144
+ permission: ->(request) { ask_the_user(request) },
145
+ fs: Files.new
146
+ )
147
+ connection.start
148
+ connection.connect(
149
+ ACP::Types::InitializeRequest.new(
150
+ protocol_version: 1,
151
+ client_capabilities: ACP::Types::ClientCapabilities.new(
152
+ fs: ACP::Types::FileSystemCapabilities.new(read_text_file: true, write_text_file: true)
153
+ )
154
+ )
107
155
  )
108
156
  ```
109
157
 
110
- - `prompt_session` and `load_session` yield the session's updates on the calling thread as they arrive and return once the agent replies. Updates outside those calls, such as the available commands after `session/new`, go to `updates`, which runs on the reader thread and must return quickly. `resume_session` does not take a block: the agent must not replay history on resume, so unlike `load_session` there is nothing to stream. `close_session` and `delete_session` end the session, discarding it on the agent, and return once the agent replies.
111
- - `permission` answers `session/request_permission` with an `ACP::Types::RequestPermissionResponse` or an `ACP::RequestError`. It runs on its own thread, so it may block while the user decides, and `cancel_session` can be sent meanwhile.
112
- - The agent may cancel any outstanding request with `$/cancel_request`: a handler that can end early checks `connection.cancelled?` — close over the connection when constructing it — and returns `ACP::RequestError.request_cancelled`, which is answered as `-32800`. The permission handler still sees the cancel on its own thread. Whatever the handler returns, the transport sends exactly one response.
113
- - `read_text_file` and `write_text_file` answer the agent's `fs/read_text_file` and `fs/write_text_file` with an `ACP::Types::ReadTextFileResponse`, an `ACP::Types::WriteTextFileResponse` or an `ACP::RequestError`. They are routed by the `fs` capabilities `connect` sends: a method the client does not advertise is answered with `-32601` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises a capability that has no handler.
114
- - `create_terminal`, `terminal_output`, `wait_for_terminal_exit`, `kill_terminal` and `release_terminal` answer the agent's `terminal/create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/kill` and `terminal/release` with the matching `ACP::Types` response or an `ACP::RequestError`. They are routed by the `terminal` capability `connect` sends, the same way as `fs`: a method the client does not advertise is answered with `-32601` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises `terminal` with any of the five missing. The transport serves each request on its own thread, so `wait_for_terminal_exit` may block without holding up the others.
115
- - `elicitation` answers the agent's `elicitation/create` with an `ACP::Types::CreateElicitationResponse` or an `ACP::RequestError`, serving both form and url mode, and `complete_elicitation` receives the agent's `elicitation/complete` notification. They are routed by the `elicitation` capabilities `connect` sends: a request whose mode the client does not advertise is answered with `-32602` without reaching the handler, and `connect` raises `ArgumentError` — before sending `initialize` — when the request advertises `elicitation.form` or `elicitation.url` with no `elicitation` handler. The client tracks the elicitation ids of the url-mode requests it serves, so a completion for an unknown or already-completed id is ignored. `complete_elicitation` runs on the reader thread and must return quickly; it may be omitted, in which case completions are only tracked.
116
- - Once the transport's input ends, a request still waiting for its answer and every one made afterwards return an `ACP::RequestError` whose `connection_closed?` is true. It is false for every error the agent sent, so a closed agent is told apart from a refusal without matching the message.
158
+ | Keyword | Methods | Capability |
159
+ | --- | --- | --- |
160
+ | `permission` | a callable | Always required |
161
+ | `fs` | `read_text_file`, `write_text_file` | `fs.read_text_file`, `fs.write_text_file` |
162
+ | `terminal` | `create_terminal`, `terminal_output`, `wait_for_terminal_exit`, `kill_terminal`, `release_terminal` | `terminal` |
163
+ | `elicitation` | `create_elicitation`, and optionally `complete_elicitation` for url mode | `elicitation.form` or `elicitation.url` |
164
+
165
+ A handler object needs only the methods of the capabilities you advertise. Each method returns the response type or an `ACP::RequestError`. `connect` raises `ArgumentError` if you advertise a capability whose method the handler doesn't define.
166
+
167
+ `permission` and the request methods each run on their own thread, so they can block while the user decides. `updates` and `complete_elicitation` must return quickly.
117
168
 
118
- Extensions work the same way in both directions: `extension_requests` and `extension_notifications` are hashes keyed by the raw `_`-prefixed wire name, `ext_request` sends one to the agent and returns its result as-is, and `ext_notify` sends an extension notification. A handler name without the `_` prefix raises `ArgumentError`, and an extension request nothing answers gets `-32601`.
169
+ ## Errors
170
+
171
+ `ACP::RequestError` is what you return to reject a request, and what you get back when the other side rejects yours. Errors are returned, never raised:
172
+
173
+ ```ruby
174
+ session = connection.new_session(request)
175
+ if session.is_a?(ACP::RequestError)
176
+ warn "#{session.code}: #{session.message}"
177
+ end
178
+ ```
179
+
180
+ - Build standard errors with `ACP::RequestError.invalid_params`, `.method_not_found`, `.resource_not_found`, `.auth_required` or `.request_cancelled`, or custom ones with `ACP::RequestError.new(code:, message:, data:)`.
181
+ - If a handler raises, the other side only sees `Internal error`. The exception is logged rather than sent, so a message can't leak paths or secrets. Return a `RequestError` when the other side should see the reason.
182
+ - If the connection drops, pending and later requests return an error whose `connection_closed?` is true.
119
183
 
120
184
  ## Types
121
185
 
122
- Every ACP request, response and notification is a generated, frozen value class under `ACP::Types` with `from_h` and `to_h`. A few of its names are deliberate enough to deserve a note:
186
+ Every ACP message is a frozen value class under `ACP::Types`, built with snake_case keyword arguments and converted with `from_h` and `to_h`.
123
187
 
124
- - `CancelRequestNotification` and `CancelNotification` are different methods with different payloads: the former is the params of the JSON-RPC-level `$/cancel_request`, which the transport handles itself, the latter is the params of ACP's `session/cancel`. Both spellings are the schema's, not a typo.
125
- - The schema names `session/new`, `session/load`, `session/list` and `session/prompt`'s params and result `NewSessionRequest`, `LoadSessionRequest`, `ListSessionsRequest` and `PromptRequest`, so the wire-shaped spellings exist as alias constants: `ACP::Types::SessionNewRequest`, `SessionLoadRequest`, `SessionListRequest`, `SessionListResponse` and `SessionPromptRequest` are the same classes as their targets. Both names are public API and here to stay — use whichever reads better.
126
- - The schema's pure string enums are generated as constant modules, for building requests and matching responses without string literals: `ACP::Types::ToolKind::EDIT`, `ACP::Types::StopReason::CANCELLED`, `ACP::Types::PlanEntryPriority::HIGH`, `ACP::Types::Role::USER` and the rest. They carry no validation of their own — the generated `from_h` checks incoming values against the schema regardless of how you build outgoing ones.
188
+ - Unions are namespaces of variant classes, such as `ContentBlock::Text` and `SessionUpdate::ToolCall`. Match on them with `is_a?` or `case`.
189
+ - String enums are modules of constants, such as `StopReason::END_TURN`, `ToolKind::EDIT` and `Role::USER`.
127
190
 
128
- ## Unstable types
191
+ ## Extensions
129
192
 
130
- Some protocol features are not part of the spec yet, so their schema lives in a separate unstable file: draft session updates (`plan_update`, `plan_removed`, `notice`, `compaction_update`, `compaction_summary_chunk`, `subagent_update`, `session_message`, `session_message_chunk`), `PromptResponse#usage`, the NE (next edit) and provider methods, and the matching capabilities. They are generated under `ACP::Types::Unstable` and load only when you opt in:
193
+ Custom methods outside the spec must start with `_`. Both connections accept `extension_requests:` and `extension_notifications:` hashes of handlers keyed by method name, and send their own with `ext_request` and `ext_notify`:
131
194
 
132
195
  ```ruby
133
- require 'acp/types/unstable'
196
+ ACP::AgentConnection.new(
197
+ # ...
198
+ extension_requests: { '_my_agent/ping' => ->(params) { { 'pong' => params } } }
199
+ )
200
+
201
+ connection.ext_request('_my_agent/ping', { 'hello' => 'world' }) # => { "pong" => { "hello" => "world" } }
134
202
  ```
135
203
 
136
- Until then, referencing `ACP::Types::Unstable` is a `NameError`, and the stable `ACP::Types` namespace is byte-identical either way: types the unstable schema shares with the stable one resolve to the same generated classes, so opting in adds names without changing or duplicating any stable one.
204
+ ## Unstable features
205
+
206
+ Draft protocol features, such as session forking, providers, usage reporting and plan and compaction updates, live under `ACP::Types::Unstable` and load only when you opt in:
137
207
 
138
- These types are **unstable**: they are not part of the ACP spec, agents and clients must not rely on the peer supporting them, and any of them may change or disappear between releases without a major version bump. When a stable schema absorbs one of them, the unstable twin goes away and the stable name stays.
208
+ ```ruby
209
+ require 'acp/types/unstable'
210
+ ```
139
211
 
140
- The connections route `session/fork`, the provider methods and `mcp/message`, but not every unstable method has routing yet: the NE methods (`nes/start`, `nes/suggest`, `nes/accept`, `nes/reject`, `nes/close`), the document notifications (`document/didOpen` and friends) and the unstable `ClientCapabilities` are generated as public API for callers wiring those methods at the transport level, and may gain connection routing later.
212
+ Once loaded, the connections support `fork_session`, `list_providers`, `set_provider` and `disable_provider`. Unstable features aren't part of the spec yet. Peers may not support them, and they can change or disappear in any release.
141
213
 
142
214
  ## Contributing
143
215
 
@@ -153,9 +153,8 @@ class ACP::AgentConnection::Client
153
153
  result = @peer.request(rpc_method, request.to_h)
154
154
  return result if result.is_a?(ACP::RequestError)
155
155
 
156
- type.from_h(result)
157
- rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError
158
- ACP::RequestError.invalid_response(result)
156
+ response = ACP::Types.parse(type, result)
157
+ response.is_a?(ACP::Types::ParseError) ? ACP::RequestError.invalid_response(result) : response
159
158
  end
160
159
 
161
160
  # @rbs rpc_method: String
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ACP::AgentConnection::OptionalRoute
4
+ # @dynamic rpc_method
5
+ attr_reader :rpc_method #: String
6
+ # @dynamic type
7
+ attr_reader :type #: ACP::Transport::_Parser
8
+ # @dynamic agent_method
9
+ attr_reader :agent_method #: Symbol
10
+ # @dynamic path
11
+ attr_reader :path #: Array[Symbol]?
12
+ # @dynamic handle
13
+ attr_reader :handle #: ^(ACP::AgentConnection::_FullAgent, untyped) -> (ACP::Transport::_Response | ACP::RequestError)
14
+
15
+ # @rbs rpc_method: String
16
+ # @rbs type: ACP::Transport::_Parser
17
+ # @rbs agent_method: Symbol
18
+ # @rbs path: Array[Symbol]?
19
+ # @rbs &handle: (ACP::AgentConnection::_FullAgent, untyped) -> (ACP::Transport::_Response | ACP::RequestError)
20
+ # @rbs return: void
21
+ def initialize(rpc_method:, type:, agent_method:, path:, &handle)
22
+ @rpc_method = rpc_method
23
+ @type = type
24
+ @agent_method = agent_method
25
+ @path = path&.freeze
26
+ @handle = handle
27
+ freeze
28
+ end
29
+
30
+ # @rbs initialize_response: ACP::Types::InitializeResponse
31
+ # @rbs return: bool
32
+ def advertised?(initialize_response)
33
+ steps = path
34
+ steps ? ACP::Protocol.advertised?(initialize_response, steps) : false
35
+ end
36
+
37
+ # @rbs initialize_response: ACP::Types::InitializeResponse
38
+ # @rbs agent: ACP::AgentConnection::_Agent
39
+ # @rbs return: bool
40
+ def routed?(initialize_response, agent)
41
+ # The schema gives a per-session method no initialize capability, since an
42
+ # agent offers it in its session responses.
43
+ path ? advertised?(initialize_response) : agent.respond_to?(agent_method)
44
+ end
45
+ end