acp-sdk 0.10.0 → 0.12.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +19 -5
  4. data/lib/acp/agent_connection/client.rb +7 -19
  5. data/lib/acp/agent_connection/optional_method/spec.rb +42 -0
  6. data/lib/acp/agent_connection/optional_method.rb +36 -0
  7. data/lib/acp/agent_connection.rb +17 -40
  8. data/lib/acp/agent_process.rb +0 -13
  9. data/lib/acp/client_connection/streams.rb +183 -0
  10. data/lib/acp/client_connection.rb +227 -384
  11. data/lib/acp/protocol.rb +33 -0
  12. data/lib/acp/request_error.rb +17 -17
  13. data/lib/acp/transport/cancellation.rb +0 -7
  14. data/lib/acp/transport/reply.rb +0 -2
  15. data/lib/acp/transport/stderr_logger.rb +1 -2
  16. data/lib/acp/transport/stdio/serving.rb +18 -0
  17. data/lib/acp/transport/stdio.rb +67 -59
  18. data/lib/acp/types/audio_content.rb +3 -46
  19. data/lib/acp/types/auth_method_terminal.rb +3 -59
  20. data/lib/acp/types/available_commands_update.rb +3 -32
  21. data/lib/acp/types/boolean_property_schema.rb +3 -41
  22. data/lib/acp/types/check.rb +210 -0
  23. data/lib/acp/types/config_option_update.rb +3 -32
  24. data/lib/acp/types/content.rb +3 -29
  25. data/lib/acp/types/{elicitation_url_mode → create_elicitation_request/mode/form}/scope.rb +1 -1
  26. data/lib/acp/types/create_elicitation_request/mode/form.rb +3 -3
  27. data/lib/acp/types/{elicitation_form_mode → create_elicitation_request/mode/url}/scope.rb +1 -1
  28. data/lib/acp/types/create_elicitation_request/mode/url.rb +3 -3
  29. data/lib/acp/types/current_mode_update.rb +3 -29
  30. data/lib/acp/types/diff.rb +3 -41
  31. data/lib/acp/types/elicitation_accept_action.rb +3 -26
  32. data/lib/acp/types/elicitation_form_mode.rb +3 -33
  33. data/lib/acp/types/elicitation_url_mode.rb +3 -35
  34. data/lib/acp/types/embedded_resource.rb +3 -44
  35. data/lib/acp/types/image_content.rb +3 -58
  36. data/lib/acp/types/integer_property_schema.rb +3 -60
  37. data/lib/acp/types/mcp_server_http.rb +3 -44
  38. data/lib/acp/types/mcp_server_sse.rb +3 -44
  39. data/lib/acp/types/multi_select_property_schema.rb +3 -70
  40. data/lib/acp/types/number_property_schema.rb +3 -60
  41. data/lib/acp/types/parse_error.rb +37 -0
  42. data/lib/acp/types/plan.rb +3 -29
  43. data/lib/acp/types/resource_link.rb +3 -79
  44. data/lib/acp/types/selected_permission_outcome.rb +3 -26
  45. data/lib/acp/types/session_config_boolean.rb +3 -21
  46. data/lib/acp/types/session_config_select.rb +3 -33
  47. data/lib/acp/types/session_info_update.rb +3 -42
  48. data/lib/acp/types/string_multi_select_items.rb +3 -29
  49. data/lib/acp/types/string_property_schema.rb +3 -105
  50. data/lib/acp/types/terminal.rb +3 -29
  51. data/lib/acp/types/text_content.rb +3 -40
  52. data/lib/acp/types/tool_call.rb +3 -115
  53. data/lib/acp/types/unstable/compaction_summary_chunk.rb +3 -35
  54. data/lib/acp/types/unstable/compaction_update.rb +3 -56
  55. data/lib/acp/types/unstable/idle_state_update.rb +3 -40
  56. data/lib/acp/types/unstable/mcp_server_acp.rb +3 -35
  57. data/lib/acp/types/unstable/nes_edit_suggestion.rb +3 -61
  58. data/lib/acp/types/unstable/nes_jump_suggestion.rb +3 -45
  59. data/lib/acp/types/unstable/nes_rename_suggestion.rb +3 -51
  60. data/lib/acp/types/unstable/nes_search_and_replace_suggestion.rb +3 -60
  61. data/lib/acp/types/unstable/notice.rb +3 -41
  62. data/lib/acp/types/unstable/plan_file.rb +3 -35
  63. data/lib/acp/types/unstable/plan_items.rb +3 -38
  64. data/lib/acp/types/unstable/plan_markdown.rb +3 -35
  65. data/lib/acp/types/unstable/plan_removed.rb +3 -26
  66. data/lib/acp/types/unstable/plan_update.rb +3 -33
  67. data/lib/acp/types/unstable/requires_action_state_update.rb +3 -21
  68. data/lib/acp/types/unstable/running_state_update.rb +3 -21
  69. data/lib/acp/types/unstable/session_message.rb +3 -56
  70. data/lib/acp/types/unstable/session_message_chunk.rb +3 -53
  71. data/lib/acp/types/unstable/subagent_update.rb +3 -65
  72. data/lib/acp/types/unstable/unknown_state_update.rb +3 -21
  73. data/lib/acp/types/unstable.rb +20 -20
  74. data/lib/acp/types/usage_update.rb +3 -41
  75. data/lib/acp/types.rb +7 -246
  76. data/lib/acp/version.rb +1 -1
  77. data/sig/generated/acp/agent_connection/client.rbs +10 -19
  78. data/sig/generated/acp/agent_connection/optional_method/spec.rbs +32 -0
  79. data/sig/generated/acp/agent_connection/optional_method.rbs +3 -0
  80. data/sig/generated/acp/agent_connection.rbs +16 -24
  81. data/sig/generated/acp/agent_process.rbs +0 -13
  82. data/sig/generated/acp/client_connection/streams.rbs +94 -0
  83. data/sig/generated/acp/client_connection.rbs +100 -159
  84. data/sig/generated/acp/protocol.rbs +9 -0
  85. data/sig/generated/acp/request_error.rbs +15 -17
  86. data/sig/generated/acp/transport/cancellation.rbs +0 -7
  87. data/sig/generated/acp/transport/reply.rbs +0 -2
  88. data/sig/generated/acp/transport/stderr_logger.rbs +1 -2
  89. data/sig/generated/acp/transport/stdio/serving.rbs +14 -0
  90. data/sig/generated/acp/transport/stdio.rbs +31 -35
  91. data/sig/generated/acp/types/audio_content.rbs +1 -27
  92. data/sig/generated/acp/types/auth_method_terminal.rbs +1 -35
  93. data/sig/generated/acp/types/available_commands_update.rbs +1 -19
  94. data/sig/generated/acp/types/boolean_property_schema.rbs +1 -27
  95. data/sig/generated/acp/types/check.rbs +115 -0
  96. data/sig/generated/acp/types/config_option_update.rbs +1 -19
  97. data/sig/generated/acp/types/content.rbs +1 -19
  98. data/sig/generated/acp/types/{elicitation_url_mode → create_elicitation_request/mode/form}/scope.rbs +2 -2
  99. data/sig/generated/acp/types/create_elicitation_request/mode/form.rbs +3 -3
  100. data/sig/generated/acp/types/{elicitation_form_mode → create_elicitation_request/mode/url}/scope.rbs +2 -2
  101. data/sig/generated/acp/types/create_elicitation_request/mode/url.rbs +3 -3
  102. data/sig/generated/acp/types/current_mode_update.rbs +1 -19
  103. data/sig/generated/acp/types/diff.rbs +1 -27
  104. data/sig/generated/acp/types/elicitation_accept_action.rbs +1 -15
  105. data/sig/generated/acp/types/elicitation_form_mode.rbs +1 -19
  106. data/sig/generated/acp/types/elicitation_url_mode.rbs +1 -23
  107. data/sig/generated/acp/types/embedded_resource.rbs +1 -23
  108. data/sig/generated/acp/types/image_content.rbs +1 -31
  109. data/sig/generated/acp/types/integer_property_schema.rbs +1 -35
  110. data/sig/generated/acp/types/mcp_server_http.rbs +1 -27
  111. data/sig/generated/acp/types/mcp_server_sse.rbs +1 -27
  112. data/sig/generated/acp/types/multi_select_property_schema.rbs +1 -39
  113. data/sig/generated/acp/types/number_property_schema.rbs +1 -35
  114. data/sig/generated/acp/types/parse_error.rbs +22 -0
  115. data/sig/generated/acp/types/plan.rbs +1 -19
  116. data/sig/generated/acp/types/resource_link.rbs +1 -43
  117. data/sig/generated/acp/types/selected_permission_outcome.rbs +1 -19
  118. data/sig/generated/acp/types/session_config_boolean.rbs +1 -15
  119. data/sig/generated/acp/types/session_config_select.rbs +1 -19
  120. data/sig/generated/acp/types/session_info_update.rbs +1 -23
  121. data/sig/generated/acp/types/string_multi_select_items.rbs +1 -19
  122. data/sig/generated/acp/types/string_property_schema.rbs +1 -51
  123. data/sig/generated/acp/types/terminal.rbs +1 -19
  124. data/sig/generated/acp/types/text_content.rbs +1 -23
  125. data/sig/generated/acp/types/tool_call.rbs +1 -51
  126. data/sig/generated/acp/types/unstable/compaction_summary_chunk.rbs +1 -23
  127. data/sig/generated/acp/types/unstable/compaction_update.rbs +1 -31
  128. data/sig/generated/acp/types/unstable/idle_state_update.rbs +1 -23
  129. data/sig/generated/acp/types/unstable/mcp_server_acp.rbs +1 -23
  130. data/sig/generated/acp/types/unstable/nes_edit_suggestion.rbs +1 -31
  131. data/sig/generated/acp/types/unstable/nes_jump_suggestion.rbs +1 -27
  132. data/sig/generated/acp/types/unstable/nes_rename_suggestion.rbs +1 -31
  133. data/sig/generated/acp/types/unstable/nes_search_and_replace_suggestion.rbs +1 -35
  134. data/sig/generated/acp/types/unstable/notice.rbs +1 -27
  135. data/sig/generated/acp/types/unstable/plan_file.rbs +1 -23
  136. data/sig/generated/acp/types/unstable/plan_items.rbs +1 -23
  137. data/sig/generated/acp/types/unstable/plan_markdown.rbs +1 -23
  138. data/sig/generated/acp/types/unstable/plan_removed.rbs +1 -19
  139. data/sig/generated/acp/types/unstable/plan_update.rbs +1 -19
  140. data/sig/generated/acp/types/unstable/requires_action_state_update.rbs +1 -15
  141. data/sig/generated/acp/types/unstable/running_state_update.rbs +1 -15
  142. data/sig/generated/acp/types/unstable/session_message.rbs +1 -31
  143. data/sig/generated/acp/types/unstable/session_message_chunk.rbs +1 -31
  144. data/sig/generated/acp/types/unstable/subagent_update.rbs +1 -35
  145. data/sig/generated/acp/types/unstable/unknown_state_update.rbs +1 -15
  146. data/sig/generated/acp/types/usage_update.rbs +1 -27
  147. data/sig/generated/acp/types.rbs +6 -143
  148. data/sig/manual/acp/agent_connection.rbs +0 -30
  149. data/sig/manual/acp/client_connection.rbs +7 -2
  150. data/sig/manual/acp/{agent_connection/optional_method/logout.rbs → protocol.rbs} +3 -3
  151. data/sig/manual/acp/transport/stdio.rbs +0 -2
  152. data/sig/manual/acp/transport.rbs +25 -2
  153. metadata +18 -40
  154. data/lib/acp/agent_connection/optional_method/authenticate.rb +0 -21
  155. data/lib/acp/agent_connection/optional_method/close_session.rb +0 -21
  156. data/lib/acp/agent_connection/optional_method/delete_session.rb +0 -21
  157. data/lib/acp/agent_connection/optional_method/disable_provider.rb +0 -23
  158. data/lib/acp/agent_connection/optional_method/fork_session.rb +0 -23
  159. data/lib/acp/agent_connection/optional_method/list_providers.rb +0 -23
  160. data/lib/acp/agent_connection/optional_method/list_sessions.rb +0 -21
  161. data/lib/acp/agent_connection/optional_method/load_session.rb +0 -21
  162. data/lib/acp/agent_connection/optional_method/logout.rb +0 -21
  163. data/lib/acp/agent_connection/optional_method/resume_session.rb +0 -21
  164. data/lib/acp/agent_connection/optional_method/set_provider.rb +0 -23
  165. data/lib/acp/types/content_chunk.rb +0 -40
  166. data/sig/generated/acp/agent_connection/optional_method/authenticate.rbs +0 -13
  167. data/sig/generated/acp/agent_connection/optional_method/close_session.rbs +0 -13
  168. data/sig/generated/acp/agent_connection/optional_method/delete_session.rbs +0 -13
  169. data/sig/generated/acp/agent_connection/optional_method/disable_provider.rbs +0 -13
  170. data/sig/generated/acp/agent_connection/optional_method/fork_session.rbs +0 -13
  171. data/sig/generated/acp/agent_connection/optional_method/list_providers.rbs +0 -13
  172. data/sig/generated/acp/agent_connection/optional_method/list_sessions.rbs +0 -13
  173. data/sig/generated/acp/agent_connection/optional_method/load_session.rbs +0 -13
  174. data/sig/generated/acp/agent_connection/optional_method/logout.rbs +0 -13
  175. data/sig/generated/acp/agent_connection/optional_method/resume_session.rbs +0 -13
  176. data/sig/generated/acp/agent_connection/optional_method/set_provider.rbs +0 -13
  177. data/sig/generated/acp/types/content_chunk.rbs +0 -25
  178. data/sig/manual/acp/agent_connection/optional_method/authenticate.rbs +0 -6
  179. data/sig/manual/acp/agent_connection/optional_method/close_session.rbs +0 -6
  180. data/sig/manual/acp/agent_connection/optional_method/delete_session.rbs +0 -6
  181. data/sig/manual/acp/agent_connection/optional_method/disable_provider.rbs +0 -6
  182. data/sig/manual/acp/agent_connection/optional_method/fork_session.rbs +0 -6
  183. data/sig/manual/acp/agent_connection/optional_method/list_providers.rbs +0 -6
  184. data/sig/manual/acp/agent_connection/optional_method/list_sessions.rbs +0 -6
  185. data/sig/manual/acp/agent_connection/optional_method/load_session.rbs +0 -6
  186. data/sig/manual/acp/agent_connection/optional_method/resume_session.rbs +0 -6
  187. data/sig/manual/acp/agent_connection/optional_method/set_provider.rbs +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0d983006184ce11ac51e9361062aa6878c01d348645f41fe226423db5d72e8b
4
- data.tar.gz: 0d50ff69cb0c6cc743d75dcd3fd943a9ddeaa4469a8f1e8d6e54be811e3c3b39
3
+ metadata.gz: 25494cd3370ba23c2d4e0055acbe0cc5ca7c30dba75abdb58ed1c5a9659ba471
4
+ data.tar.gz: 71522f1fd9a8bf5370f97719a78bd88149c9dfecbaa986031788693cde1e1c88
5
5
  SHA512:
6
- metadata.gz: 4296fbbf1fa985cb3913f2019528c06658fd8fcac285a238abf79073d2d5eb13d081e69da57f05476569967189f732c804f6dc851e291709c0b4052572b17d9f
7
- data.tar.gz: 8f0a5bedfa32615f867111297c4fc593f2b567fd77a55e76dda4eabe57bd8494e225de93b470f1ac294f4b99a57797fda4d7f69704693fb8cd322e65fe650f59
6
+ metadata.gz: a29109841f97f7de1f5c1951e82379179741ba08100e6514949a17033e85b646eb35781b7bf01f26eb754831e510bb7de07be13cf0fa02d0817b102f5f370097
7
+ data.tar.gz: 594b91f54fb5722b3d884d210e485606c13a7902f1300e4fe42a467d1b2ed5725b087ad12346b4fe4e46df3fbf2d0a1d82100e6ea47091237f5017755f4264f3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/bottrall/acp-sdk/compare/v0.11.0...v0.12.0) (2026-10-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * add RequestError#connection_closed? predicate ([#145](https://github.com/bottrall/acp-sdk/issues/145)) ([16692be](https://github.com/bottrall/acp-sdk/commit/16692be92531e2ddbf14d6547a1bbb431cc151d3)), closes [#108](https://github.com/bottrall/acp-sdk/issues/108)
9
+ * expose Stdio serving state and drain ([#148](https://github.com/bottrall/acp-sdk/issues/148)) ([e8169c1](https://github.com/bottrall/acp-sdk/commit/e8169c1902d8f8ab185cc809e14f08ea98300007)), closes [#107](https://github.com/bottrall/acp-sdk/issues/107)
10
+
11
+ ## [0.11.0](https://github.com/bottrall/acp-sdk/compare/v0.10.0...v0.11.0) (2026-10-12)
12
+
13
+
14
+ ### Features
15
+
16
+ * standardize ClientConnection senders on verb-noun naming ([#118](https://github.com/bottrall/acp-sdk/issues/118)) ([1b844ec](https://github.com/bottrall/acp-sdk/commit/1b844ecccef5b8714c5e3f2d0299713f0ee09e0f))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * answer malformed elicitation params as invalid params ([#133](https://github.com/bottrall/acp-sdk/issues/133)) ([b9e1c51](https://github.com/bottrall/acp-sdk/commit/b9e1c510b60f78986998ba8be56c8a039488ee95)), closes [#125](https://github.com/bottrall/acp-sdk/issues/125)
22
+ * answer the agent when a permission handler raises ([#134](https://github.com/bottrall/acp-sdk/issues/134)) ([a0f61a9](https://github.com/bottrall/acp-sdk/commit/a0f61a9db3589848fafadb7b4e19ad70b260f508)), closes [#124](https://github.com/bottrall/acp-sdk/issues/124)
23
+ * cancel the echo agent's in-flight turn when its session is closed or deleted ([#136](https://github.com/bottrall/acp-sdk/issues/136)) ([e04a8c0](https://github.com/bottrall/acp-sdk/commit/e04a8c0bb672b569d68029c63eaee826d00c84fd)), closes [#130](https://github.com/bottrall/acp-sdk/issues/130)
24
+ * **codegen:** keep the unset sentinel out of clearable to_h conversions ([#140](https://github.com/bottrall/acp-sdk/issues/140)) ([67769d7](https://github.com/bottrall/acp-sdk/commit/67769d7ff5d9f93dfe3f3ea2ba4ac71126caad60)), closes [#131](https://github.com/bottrall/acp-sdk/issues/131)
25
+ * **codegen:** resolve primitive unions from each option's resolved shape ([#141](https://github.com/bottrall/acp-sdk/issues/141)) ([0b574ae](https://github.com/bottrall/acp-sdk/commit/0b574ae9d33c6c10eb0a00866b6c07f1601e01e8)), closes [#132](https://github.com/bottrall/acp-sdk/issues/132)
26
+ * **codegen:** subsume union-variant twins into variant aliases ([#103](https://github.com/bottrall/acp-sdk/issues/103)) ([bdc0005](https://github.com/bottrall/acp-sdk/commit/bdc0005460c0fd133a09f10cf61f9f5717012e16)), closes [#93](https://github.com/bottrall/acp-sdk/issues/93)
27
+ * echo slash commands followed only by whitespace ([#135](https://github.com/bottrall/acp-sdk/issues/135)) ([bb4bde6](https://github.com/bottrall/acp-sdk/commit/bb4bde6951ceb54ac87c92ec335dcdd319fd7b81)), closes [#129](https://github.com/bottrall/acp-sdk/issues/129)
28
+ * read mcpCapabilities alone when deciding whether MCP is advertised ([#139](https://github.com/bottrall/acp-sdk/issues/139)) ([a6e882e](https://github.com/bottrall/acp-sdk/commit/a6e882ebf3c69f0aa8692100e81e61a085007a8b)), closes [#128](https://github.com/bottrall/acp-sdk/issues/128)
29
+ * return a null or false result instead of "Connection closed" ([#137](https://github.com/bottrall/acp-sdk/issues/137)) ([19be64d](https://github.com/bottrall/acp-sdk/commit/19be64df6064e370bb23614a3b8df1056649cd3b)), closes [#127](https://github.com/bottrall/acp-sdk/issues/127)
30
+ * settle a pending request when its response is malformed ([#138](https://github.com/bottrall/acp-sdk/issues/138)) ([7d0586e](https://github.com/bottrall/acp-sdk/commit/7d0586e856a54e5779e4d523bd0862ab57f44b10)), closes [#126](https://github.com/bottrall/acp-sdk/issues/126)
31
+
3
32
  ## [0.10.0](https://github.com/bottrall/acp-sdk/compare/v0.9.0...v0.10.0) (2026-10-07)
4
33
 
5
34
 
data/README.md CHANGED
@@ -58,14 +58,17 @@ The agent's contract:
58
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
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
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.
61
62
 
62
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`.
63
64
 
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.
66
+
64
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`.
65
68
 
66
69
  ## Driving an agent
67
70
 
68
- `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. `session_set_mode` sends `session/set_mode`, and `session_set_config_option` sends `session/set_config_option` and returns the session's complete option list, which replaces any state held from an earlier reply. `session_fork` 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`. `providers_list`, `providers_set` and `providers_disable` 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.
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.
69
72
 
70
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.
71
74
 
@@ -81,9 +84,9 @@ response = ACP::AgentProcess.spawn(
81
84
  updates: ->(notification) { show(notification) }
82
85
  ) do |connection|
83
86
  connection.connect(ACP::Types::InitializeRequest.new(protocol_version: 1))
84
- session = connection.session_new(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
87
+ session = connection.new_session(ACP::Types::NewSessionRequest.new(cwd: Dir.pwd, mcp_servers: []))
85
88
  prompt = [ACP::Types::ContentBlock::Text.new(text: 'hello')]
86
- connection.session_prompt(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
89
+ connection.prompt_session(ACP::Types::PromptRequest.new(session_id: session.session_id, prompt:)) do |update|
87
90
  print update.content.text if update.is_a?(ACP::Types::SessionUpdate::AgentMessageChunk)
88
91
  end
89
92
  end
@@ -104,15 +107,24 @@ connection = ACP::ClientConnection.new(
104
107
  )
105
108
  ```
106
109
 
107
- - `session_prompt` and `session_load` 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. `session_resume` does not take a block: the agent must not replay history on resume, so unlike `session_load` there is nothing to stream. `session_close` and `session_delete` end the session, discarding it on the agent, and return once the agent replies.
108
- - `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 `session_cancel` can be sent meanwhile.
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.
109
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.
110
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.
111
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.
112
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.
113
117
 
114
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`.
115
119
 
120
+ ## Types
121
+
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:
123
+
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.
127
+
116
128
  ## Unstable types
117
129
 
118
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:
@@ -125,6 +137,8 @@ Until then, referencing `ACP::Types::Unstable` is a `NameError`, and the stable
125
137
 
126
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.
127
139
 
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.
141
+
128
142
  ## Contributing
129
143
 
130
144
  Bug reports and pull requests are welcome on [GitHub](https://github.com/bottrall/acp-sdk). See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and workflow. Everyone interacting in the project is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
@@ -1,20 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ACP::AgentConnection::Client
4
- # @rbs @peer: ACP::AgentConnection::_Peer
4
+ # @rbs @peer: ACP::Transport::_Peer
5
5
  # @rbs @mcp_advertised: bool
6
6
 
7
7
  # @dynamic capabilities, capabilities=
8
8
  attr_accessor :capabilities #: ACP::Types::ClientCapabilities?
9
9
 
10
- # @rbs peer: ACP::AgentConnection::_Peer
10
+ # @rbs peer: ACP::Transport::_Peer
11
11
  # @rbs mcp_advertised: bool
12
12
  # @rbs return: void
13
13
  def initialize(peer:, mcp_advertised: false)
14
14
  @peer = peer
15
- # The agent's own initialize advertisement of mcpCapabilities.acp, which
16
- # licenses this side's mcp/message sends; the client's advertisement is
17
- # the separate capabilities accessor.
18
15
  @mcp_advertised = mcp_advertised
19
16
  @capabilities = nil
20
17
  end
@@ -33,9 +30,6 @@ class ACP::AgentConnection::Client
33
30
  update(session_id, ACP::Types::SessionUpdate::AvailableCommandsUpdate.new(available_commands: commands))
34
31
  end
35
32
 
36
- # Blocks the calling thread until the client answers or the connection
37
- # closes.
38
- #
39
33
  # @rbs request: ACP::Types::RequestPermissionRequest
40
34
  # @rbs return: ACP::Types::RequestPermissionResponse | ACP::RequestError
41
35
  def request_permission(request)
@@ -123,10 +117,6 @@ class ACP::AgentConnection::Client
123
117
  @peer.notify('elicitation/complete', notification.to_h)
124
118
  end
125
119
 
126
- # Sends one inner MCP operation to the client's declared ACP MCP server. The
127
- # reply's result may be null, and a reply matching neither carrier branch
128
- # comes back as the raw hash.
129
- #
130
120
  # @rbs request: ACP::Types::Unstable::MessageMcpRequest
131
121
  # @rbs return: (ACP::Types::Unstable::MessageMcpResponse::t | ACP::RequestError)
132
122
  def mcp_message(request)
@@ -135,9 +125,7 @@ class ACP::AgentConnection::Client
135
125
  call('mcp/message', ACP::Types::Unstable::MessageMcpResponse, request)
136
126
  end
137
127
 
138
- # Sends an extension request, keyed by its raw `_`-prefixed wire name, and
139
- # returns the client's reply as-is: extension methods have no schema to
140
- # parse the result into.
128
+ # Extension methods have no schema to parse the result into.
141
129
  #
142
130
  # @rbs method: String
143
131
  # @rbs params: untyped
@@ -158,8 +146,8 @@ class ACP::AgentConnection::Client
158
146
  private
159
147
 
160
148
  # @rbs rpc_method: String
161
- # @rbs type: ACP::AgentConnection::_Parser
162
- # @rbs request: ACP::AgentConnection::_Response
149
+ # @rbs type: ACP::Transport::_Parser
150
+ # @rbs request: ACP::Transport::_Response
163
151
  # @rbs return: untyped
164
152
  def call(rpc_method, type, request)
165
153
  result = @peer.request(rpc_method, request.to_h)
@@ -171,8 +159,8 @@ class ACP::AgentConnection::Client
171
159
  end
172
160
 
173
161
  # @rbs rpc_method: String
174
- # @rbs type: ACP::AgentConnection::_Parser
175
- # @rbs request: ACP::AgentConnection::_Response
162
+ # @rbs type: ACP::Transport::_Parser
163
+ # @rbs request: ACP::Transport::_Response
176
164
  # @rbs return: untyped
177
165
  def terminal_call(rpc_method, type, request)
178
166
  return ACP::RequestError.unadvertised('terminal') unless capabilities&.terminal
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ACP::AgentConnection::OptionalMethod::Spec
4
+ # @dynamic rpc_method
5
+ attr_reader :rpc_method #: String
6
+ # @dynamic agent_method
7
+ attr_reader :agent_method #: Symbol
8
+ # @dynamic path
9
+ attr_reader :path #: Array[Symbol]
10
+
11
+ # @rbs rpc_method: String
12
+ # @rbs agent_method: Symbol
13
+ # @rbs path: Array[Symbol]
14
+ # @rbs return: void
15
+ def initialize(rpc_method:, agent_method:, path:)
16
+ @rpc_method = rpc_method
17
+ @agent_method = agent_method
18
+ @path = path.freeze
19
+ freeze
20
+ end
21
+
22
+ # Readers missing along the chain count as unadvertised, so the stable
23
+ # capabilities answer false for the unstable-only paths (fork, providers).
24
+ #
25
+ # @rbs initialize_response: ACP::Types::InitializeResponse
26
+ # @rbs return: bool
27
+ def advertised?(initialize_response)
28
+ # reduce's U pins to the InitializeResponse argument, but the chain walks
29
+ # through unrelated capability types.
30
+ value = path.reduce(initialize_response) { |node, name| follow(node, name) } #: untyped
31
+ value ? true : false
32
+ end
33
+
34
+ private
35
+
36
+ # @rbs node: untyped
37
+ # @rbs name: Symbol
38
+ # @rbs return: untyped
39
+ def follow(node, name)
40
+ node.respond_to?(name) ? node.public_send(name) : nil
41
+ end
42
+ end
@@ -1,4 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ACP::AgentConnection::OptionalMethod
4
+ SPECS = [
5
+ Spec.new(rpc_method: 'authenticate', agent_method: :authenticate, path: %i[auth_methods any?]),
6
+ Spec.new(rpc_method: 'session/load', agent_method: :load_session, path: %i[agent_capabilities load_session]),
7
+ Spec.new(
8
+ rpc_method: 'session/list',
9
+ agent_method: :list_sessions,
10
+ path: %i[agent_capabilities session_capabilities list]
11
+ ),
12
+ Spec.new(
13
+ rpc_method: 'session/resume',
14
+ agent_method: :resume_session,
15
+ path: %i[agent_capabilities session_capabilities resume]
16
+ ),
17
+ Spec.new(
18
+ rpc_method: 'session/close',
19
+ agent_method: :close_session,
20
+ path: %i[agent_capabilities session_capabilities close]
21
+ ),
22
+ Spec.new(
23
+ rpc_method: 'session/delete',
24
+ agent_method: :delete_session,
25
+ path: %i[agent_capabilities session_capabilities delete]
26
+ ),
27
+ Spec.new(rpc_method: 'logout', agent_method: :logout, path: %i[agent_capabilities auth logout])
28
+ ].freeze #: Array[Spec]
29
+
30
+ UNSTABLE_SPECS = [
31
+ Spec.new(
32
+ rpc_method: 'session/fork',
33
+ agent_method: :fork_session,
34
+ path: %i[agent_capabilities session_capabilities fork]
35
+ ),
36
+ Spec.new(rpc_method: 'providers/list', agent_method: :list_providers, path: %i[agent_capabilities providers]),
37
+ Spec.new(rpc_method: 'providers/set', agent_method: :set_provider, path: %i[agent_capabilities providers]),
38
+ Spec.new(rpc_method: 'providers/disable', agent_method: :disable_provider, path: %i[agent_capabilities providers])
39
+ ].freeze #: Array[Spec]
4
40
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ACP::AgentConnection
4
- # @rbs @transport: ACP::AgentConnection::_Transport
4
+ # @rbs @transport: ACP::Transport::_Transport
5
5
  # @rbs @capabilities: ACP::Types::AgentCapabilities
6
6
  # @rbs @agent_info: ACP::Types::Implementation?
7
7
  # @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
@@ -10,16 +10,6 @@ class ACP::AgentConnection
10
10
  # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
11
11
  # @rbs @logger: ACP::Transport::_Logger
12
12
 
13
- PROTOCOL_VERSION = 1 #: Integer
14
- OPTIONAL = [
15
- ACP::AgentConnection::OptionalMethod::LoadSession,
16
- ACP::AgentConnection::OptionalMethod::ListSessions,
17
- ACP::AgentConnection::OptionalMethod::Authenticate,
18
- ACP::AgentConnection::OptionalMethod::ResumeSession,
19
- ACP::AgentConnection::OptionalMethod::CloseSession,
20
- ACP::AgentConnection::OptionalMethod::DeleteSession,
21
- ACP::AgentConnection::OptionalMethod::Logout
22
- ].freeze #: Array[ACP::AgentConnection::_OptionalMethod]
23
13
  # The schema has no initialize capability for modes or config options: an
24
14
  # agent offers them per session, in its session responses, so they are
25
15
  # routed whenever the agent defines them.
@@ -28,7 +18,7 @@ class ACP::AgentConnection
28
18
  'session/set_config_option' => :change_session_config_option
29
19
  }.freeze #: Hash[String, Symbol]
30
20
 
31
- # @rbs transport: ACP::AgentConnection::_Transport
21
+ # @rbs transport: ACP::Transport::_Transport
32
22
  # @rbs capabilities: ACP::Types::AgentCapabilities
33
23
  # @rbs agent_info: ACP::Types::Implementation?
34
24
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
@@ -87,7 +77,7 @@ class ACP::AgentConnection
87
77
 
88
78
  # @rbs agent: ACP::AgentConnection::_Agent
89
79
  # @rbs client: ACP::AgentConnection::Client
90
- # @rbs return: Hash[String, ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)]
80
+ # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)]
91
81
  def requests(agent, client)
92
82
  # Safe: start drops the optional handlers initialize does not advertise
93
83
  # or the agent does not define, and checks the agent defines the rest.
@@ -115,9 +105,9 @@ class ACP::AgentConnection
115
105
  # advertises fork or providers.
116
106
  #
117
107
  # @rbs full: ACP::AgentConnection::_FullAgent
118
- # @rbs return: Hash[String, ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)]
108
+ # @rbs return: Hash[String, ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)]
119
109
  def unstable_requests(full)
120
- return {} unless defined?(ACP::Types::Unstable)
110
+ return {} unless ACP::Types.unstable_enabled?
121
111
 
122
112
  {
123
113
  'session/fork' => route(ACP::Types::Unstable::ForkSessionRequest) { |request| full.fork_session(request) },
@@ -127,20 +117,16 @@ class ACP::AgentConnection
127
117
  }
128
118
  end
129
119
 
130
- # ForkSession's and the provider methods' advertised? reference the unstable
131
- # types, so they can only be probed when the caller opted in with
132
- # `require 'acp/types/unstable'`.
120
+ # The unstable specs pair with the unstable request routes, which reference
121
+ # the unstable request classes, so both are only probed after the caller
122
+ # opted in with `require 'acp/types/unstable'`.
133
123
  #
134
- # @rbs return: Array[ACP::AgentConnection::_OptionalMethod]
124
+ # @rbs return: Array[ACP::AgentConnection::OptionalMethod::Spec]
135
125
  def optional_methods
136
- return OPTIONAL unless defined?(ACP::Types::Unstable)
126
+ specs = ACP::AgentConnection::OptionalMethod::SPECS
127
+ return specs unless ACP::Types.unstable_enabled?
137
128
 
138
- OPTIONAL + [
139
- ACP::AgentConnection::OptionalMethod::ForkSession,
140
- ACP::AgentConnection::OptionalMethod::ListProviders,
141
- ACP::AgentConnection::OptionalMethod::SetProvider,
142
- ACP::AgentConnection::OptionalMethod::DisableProvider
143
- ]
129
+ specs + ACP::AgentConnection::OptionalMethod::UNSTABLE_SPECS
144
130
  end
145
131
 
146
132
  # @rbs agent: ACP::AgentConnection::_Agent
@@ -165,18 +151,9 @@ class ACP::AgentConnection
165
151
  agent.cancel(notification)
166
152
  end
167
153
 
168
- # The unstable gates reference the unstable types, so they can only be
169
- # probed when the caller opted in with `require 'acp/types/unstable'`; the
170
- # stable McpCapabilities has no acp field at all.
171
- #
172
154
  # @rbs return: bool
173
155
  def mcp_acp_advertised?
174
- return false unless defined?(ACP::Types::Unstable)
175
-
176
- mcp = @capabilities.mcp_capabilities #: ACP::Types::McpCapabilities | ACP::Types::Unstable::McpCapabilities?
177
- return false unless mcp.is_a?(ACP::Types::Unstable::McpCapabilities)
178
-
179
- mcp.acp ? true : false
156
+ ACP::Protocol.mcp_acp_advertised?(@capabilities)
180
157
  end
181
158
 
182
159
  # A notification has no reply to carry a parse failure.
@@ -195,9 +172,9 @@ class ACP::AgentConnection
195
172
 
196
173
  # Generated from_h raises on a missing key or a value of the wrong shape.
197
174
  #
198
- # @rbs type: ACP::AgentConnection::_Parser
199
- # @rbs &handle: (untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
200
- # @rbs return: ^(untyped) -> (ACP::AgentConnection::_Response | ACP::RequestError | ACP::Transport::Reply)
175
+ # @rbs type: ACP::Transport::_Parser
176
+ # @rbs &handle: (untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
177
+ # @rbs return: ^(untyped) -> (ACP::Transport::_Response | ACP::RequestError | ACP::Transport::Reply)
201
178
  def route(type, &)
202
179
  lambda do |params|
203
180
  request = type.from_h(params)
@@ -223,7 +200,7 @@ class ACP::AgentConnection
223
200
  # @rbs return: ACP::Types::InitializeResponse
224
201
  def initialize_response(auth_methods)
225
202
  ACP::Types::InitializeResponse.new(
226
- protocol_version: PROTOCOL_VERSION,
203
+ protocol_version: ACP::Protocol::PROTOCOL_VERSION,
227
204
  agent_capabilities: @capabilities,
228
205
  auth_methods:,
229
206
  agent_info: @agent_info
@@ -2,8 +2,6 @@
2
2
 
3
3
  require 'open3'
4
4
 
5
- # A child agent process and the ACP::ClientConnection wired to its stdio.
6
- # Spawn one with ACP::AgentProcess.spawn.
7
5
  class ACP::AgentProcess
8
6
  # @rbs @connection: ACP::ClientConnection
9
7
  # @rbs @reader: Thread
@@ -21,16 +19,9 @@ class ACP::AgentProcess
21
19
  # @dynamic connection
22
20
  attr_reader :connection #: ACP::ClientConnection
23
21
 
24
- # Child stderr captured when spawn was given stderr: :capture, else nil.
25
- #
26
22
  # @dynamic stderr
27
23
  attr_reader :stderr #: String?
28
24
 
29
- # Starts a command with args, env and cwd, wires its stdio to a new
30
- # ACP::ClientConnection and starts it. With a block, yields the connection
31
- # and this process, then terminates the child when the block exits; without
32
- # one, returns the process and terminate is the caller's job.
33
- #
34
25
  # @rbs command: String
35
26
  # @rbs *args: String
36
27
  # @rbs env: Hash[String, String]?
@@ -72,10 +63,6 @@ class ACP::AgentProcess
72
63
  end
73
64
  end
74
65
 
75
- # Closes stdin, waits for the child to exit — sending TERM and then KILL if
76
- # it will not — and joins the transport reader before closing the pipe it
77
- # reads.
78
- #
79
66
  # @rbs return: void
80
67
  def terminate
81
68
  @stdin.close unless @stdin.closed?
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ # It holds no send facade of its own — requests go through the sender and
4
+ # answers to the agent ride the connection's routes — so it is unit-testable
5
+ # without a transport.
6
+ class ACP::ClientConnection::Streams
7
+ CANCELLED = ACP::Types::RequestPermissionResponse.new(
8
+ outcome: ACP::Types::RequestPermissionOutcome::Cancelled.new
9
+ ) #: ACP::Types::RequestPermissionResponse
10
+
11
+ # @rbs @sender: ACP::ClientConnection::Streams::_Sender
12
+ # @rbs @permission: ACP::ClientConnection::_PermissionHandler
13
+ # @rbs @updates: ACP::ClientConnection::_UpdateHandler
14
+ # @rbs @complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
15
+ # @rbs @logger: ACP::Transport::_Logger
16
+ # @rbs @lock: Thread::Mutex
17
+ # @rbs @streams: Hash[String, Thread::Queue]
18
+ # @rbs @pending_permissions: Hash[String, Array[Thread::Queue]]
19
+ # @rbs @outstanding_elicitations: Hash[String, bool]
20
+
21
+ # @rbs sender: ACP::ClientConnection::Streams::_Sender
22
+ # @rbs permission: ACP::ClientConnection::_PermissionHandler
23
+ # @rbs updates: ACP::ClientConnection::_UpdateHandler
24
+ # @rbs logger: ACP::Transport::_Logger
25
+ # @rbs complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
26
+ # @rbs return: void
27
+ def initialize(sender:, permission:, updates:, logger:, complete_elicitation: nil)
28
+ @sender = sender
29
+ @permission = permission
30
+ @updates = updates
31
+ @complete_elicitation = complete_elicitation
32
+ @logger = logger
33
+ @lock = Mutex.new
34
+ @streams = {}
35
+ @pending_permissions = {}
36
+ @outstanding_elicitations = {}
37
+ end
38
+
39
+ # The request waits on its own thread so the caller's thread can yield each
40
+ # update as it arrives. The reader thread queues an update before it settles
41
+ # a reply sent after it, so the queue closes only after the last one.
42
+ #
43
+ # @rbs session_id: String
44
+ # @rbs method: String
45
+ # @rbs params: Hash[String, untyped]
46
+ # @rbs &block: (ACP::Types::SessionUpdate::t) -> void
47
+ # @rbs return: (Hash[String, untyped] | ACP::RequestError)
48
+ def stream(session_id, method, params, &)
49
+ queue = Thread::Queue.new
50
+ # The check and the insert share one lock acquisition so a concurrent
51
+ # stream cannot register between them.
52
+ claimed = @lock.synchronize do
53
+ if @streams.key?(session_id)
54
+ false
55
+ else
56
+ @streams[session_id] = queue
57
+ true
58
+ end
59
+ end
60
+ unless claimed
61
+ return ACP::RequestError.new(
62
+ code: ACP::RequestError::INVALID_REQUEST, message: "A stream is already open for session #{session_id}"
63
+ )
64
+ end
65
+ requester = Thread.new do
66
+ @sender.request(method, params)
67
+ ensure
68
+ @lock.synchronize { @streams.delete(session_id)&.close }
69
+ end
70
+ while (update = queue.pop)
71
+ yield update
72
+ end
73
+ requester.value
74
+ end
75
+
76
+ # Pushes under the lock so a stream cannot close its queue between the
77
+ # lookup and the push. A malformed notification has no reply to carry the
78
+ # failure.
79
+ #
80
+ # @rbs params: untyped
81
+ # @rbs return: void
82
+ def dispatch(params)
83
+ notification = ACP::Types::SessionNotification.from_h(params)
84
+ rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
85
+ @logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
86
+ else
87
+ queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
88
+ @updates.call(notification) unless queue
89
+ end
90
+
91
+ # The handler runs on its own thread so a cancelled request is answered
92
+ # without waiting for it, and whatever it returns after that is pushed to a
93
+ # queue nobody reads.
94
+ #
95
+ # @rbs params: untyped
96
+ # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
97
+ def request_permission(params)
98
+ request = ACP::Types::RequestPermissionRequest.from_h(params)
99
+ rescue ACP::Types::ParseError => e
100
+ ACP::RequestError.invalid_params([e.message])
101
+ rescue KeyError, TypeError, NoMethodError
102
+ ACP::RequestError.invalid_params
103
+ else
104
+ replies = Thread::Queue.new
105
+ register_permission(request.session_id, replies)
106
+ # The handler runs on the transport's serve thread, so the serving
107
+ # identity must be carried over for cancelled? to see the cancel there.
108
+ serving = @sender.cancellation
109
+ Thread.new do
110
+ ACP::Transport::Cancellation.current = serving
111
+ # A raised StandardError is not a returned ACP::RequestError, so the two
112
+ # need distinct shapes on the queue.
113
+ replies << begin
114
+ [:value, @permission.call(request)]
115
+ rescue StandardError => e
116
+ [:raised, e]
117
+ end
118
+ ensure
119
+ unregister_permission(request.session_id, replies)
120
+ end
121
+ settled = replies.pop
122
+ # Re-raised so the transport's invoke turns it into an error reply, as it
123
+ # would if the handler ran on the serve thread itself.
124
+ raise settled[1] if settled[0] == :raised
125
+
126
+ settled[1]
127
+ end
128
+
129
+ # The spec requires a pending session/request_permission to be answered with
130
+ # the cancelled outcome once the turn is cancelled, so every queue registered
131
+ # for the session gets one before this returns.
132
+ #
133
+ # @rbs session_id: String
134
+ # @rbs return: void
135
+ def cancel(session_id)
136
+ pending = @lock.synchronize { @pending_permissions.delete(session_id) }
137
+ pending&.each { |replies| replies << [:value, CANCELLED] }
138
+ end
139
+
140
+ # @rbs elicitation_id: String
141
+ # @rbs return: void
142
+ def register_elicitation(elicitation_id)
143
+ @lock.synchronize { @outstanding_elicitations[elicitation_id] = true }
144
+ end
145
+
146
+ # The spec requires a completion for an unknown or already-completed
147
+ # elicitation id to be ignored, so only an id registered by a served
148
+ # url-mode request reaches the handler. It runs on the transport's reader
149
+ # thread, so it must return quickly.
150
+ #
151
+ # @rbs params: untyped
152
+ # @rbs return: void
153
+ def complete_elicitation(params)
154
+ notification = ACP::Types::CompleteElicitationNotification.from_h(params)
155
+ rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
156
+ @logger.warn("dropped malformed elicitation/complete: #{e.class}: #{e.message}")
157
+ else
158
+ known = @lock.synchronize { @outstanding_elicitations.delete(notification.elicitation_id) }
159
+ @complete_elicitation&.call(notification) if known
160
+ end
161
+
162
+ private
163
+
164
+ # @rbs session_id: String
165
+ # @rbs replies: Thread::Queue
166
+ # @rbs return: void
167
+ def register_permission(session_id, replies)
168
+ @lock.synchronize { (@pending_permissions[session_id] ||= []) << replies }
169
+ end
170
+
171
+ # @rbs session_id: String
172
+ # @rbs replies: Thread::Queue
173
+ # @rbs return: void
174
+ def unregister_permission(session_id, replies)
175
+ @lock.synchronize do
176
+ pending = @pending_permissions[session_id]
177
+ if pending
178
+ pending.delete(replies)
179
+ @pending_permissions.delete(session_id) if pending.empty?
180
+ end
181
+ end
182
+ end
183
+ end