acp-sdk 0.7.0 → 0.8.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +2 -1
  4. data/lib/acp/agent_connection/client.rb +1 -1
  5. data/lib/acp/agent_connection.rb +3 -1
  6. data/lib/acp/client_connection.rb +93 -4
  7. data/lib/acp/request_error.rb +15 -2
  8. data/lib/acp/types/agent_auth_capabilities.rb +4 -1
  9. data/lib/acp/types/agent_capabilities.rb +20 -5
  10. data/lib/acp/types/annotations.rb +6 -3
  11. data/lib/acp/types/audio_content.rb +8 -3
  12. data/lib/acp/types/auth_capabilities.rb +1 -1
  13. data/lib/acp/types/auth_method/terminal.rb +11 -5
  14. data/lib/acp/types/auth_method_agent.rb +6 -1
  15. data/lib/acp/types/auth_method_terminal.rb +11 -5
  16. data/lib/acp/types/authenticate_request.rb +1 -1
  17. data/lib/acp/types/available_command.rb +3 -3
  18. data/lib/acp/types/available_commands_update.rb +7 -4
  19. data/lib/acp/types/blob_resource_contents.rb +6 -1
  20. data/lib/acp/types/boolean_property_schema.rb +6 -1
  21. data/lib/acp/types/cancel_notification.rb +4 -1
  22. data/lib/acp/types/cancel_request_notification.rb +9 -1
  23. data/lib/acp/types/client_capabilities.rb +15 -5
  24. data/lib/acp/types/client_session_capabilities.rb +6 -3
  25. data/lib/acp/types/close_session_request.rb +4 -1
  26. data/lib/acp/types/complete_elicitation_notification.rb +4 -1
  27. data/lib/acp/types/config_option_update.rb +7 -4
  28. data/lib/acp/types/content.rb +4 -1
  29. data/lib/acp/types/content_block/audio.rb +8 -3
  30. data/lib/acp/types/content_block/image.rb +9 -4
  31. data/lib/acp/types/content_block/resource.rb +11 -2
  32. data/lib/acp/types/content_block/resource_link.rb +12 -7
  33. data/lib/acp/types/content_block/text.rb +7 -2
  34. data/lib/acp/types/content_chunk.rb +2 -2
  35. data/lib/acp/types/cost.rb +5 -1
  36. data/lib/acp/types/create_elicitation_request/mode/form.rb +5 -1
  37. data/lib/acp/types/create_elicitation_request/mode/url.rb +2 -2
  38. data/lib/acp/types/create_elicitation_request.rb +1 -1
  39. data/lib/acp/types/create_elicitation_response/action/accept.rb +6 -1
  40. data/lib/acp/types/create_terminal_request.rb +12 -6
  41. data/lib/acp/types/create_terminal_response.rb +4 -1
  42. data/lib/acp/types/current_mode_update.rb +4 -1
  43. data/lib/acp/types/delete_session_request.rb +4 -1
  44. data/lib/acp/types/diff.rb +6 -1
  45. data/lib/acp/types/elicitation_accept_action.rb +6 -1
  46. data/lib/acp/types/elicitation_capabilities.rb +2 -2
  47. data/lib/acp/types/elicitation_form_mode.rb +5 -1
  48. data/lib/acp/types/elicitation_property_schema/boolean_property_schema.rb +6 -1
  49. data/lib/acp/types/elicitation_property_schema/integer_property_schema.rb +5 -5
  50. data/lib/acp/types/elicitation_property_schema/multi_select_property_schema.rb +9 -6
  51. data/lib/acp/types/elicitation_property_schema/number_property_schema.rb +5 -5
  52. data/lib/acp/types/elicitation_property_schema/string_property_schema.rb +15 -9
  53. data/lib/acp/types/elicitation_request_scope.rb +8 -1
  54. data/lib/acp/types/elicitation_schema.rb +11 -5
  55. data/lib/acp/types/elicitation_session_scope.rb +4 -1
  56. data/lib/acp/types/elicitation_url_mode.rb +2 -2
  57. data/lib/acp/types/embedded_resource.rb +11 -2
  58. data/lib/acp/types/enum_option.rb +6 -1
  59. data/lib/acp/types/env_variable.rb +5 -1
  60. data/lib/acp/types/file_system_capabilities.rb +5 -1
  61. data/lib/acp/types/http_header.rb +5 -1
  62. data/lib/acp/types/image_content.rb +9 -4
  63. data/lib/acp/types/implementation.rb +6 -1
  64. data/lib/acp/types/initialize_request.rb +18 -3
  65. data/lib/acp/types/initialize_response.rb +17 -4
  66. data/lib/acp/types/integer_property_schema.rb +5 -5
  67. data/lib/acp/types/kill_terminal_request.rb +5 -1
  68. data/lib/acp/types/list_sessions_request.rb +5 -1
  69. data/lib/acp/types/list_sessions_response.rb +5 -2
  70. data/lib/acp/types/load_session_request.rb +14 -4
  71. data/lib/acp/types/load_session_response.rb +5 -2
  72. data/lib/acp/types/mcp_capabilities.rb +5 -1
  73. data/lib/acp/types/mcp_server/http.rb +6 -3
  74. data/lib/acp/types/mcp_server/sse.rb +6 -3
  75. data/lib/acp/types/mcp_server_http.rb +6 -3
  76. data/lib/acp/types/mcp_server_sse.rb +6 -3
  77. data/lib/acp/types/mcp_server_stdio.rb +10 -4
  78. data/lib/acp/types/multi_select_items/string_multi_select_items.rb +4 -1
  79. data/lib/acp/types/multi_select_property_schema.rb +9 -6
  80. data/lib/acp/types/new_session_request.rb +13 -3
  81. data/lib/acp/types/new_session_response.rb +6 -3
  82. data/lib/acp/types/number_property_schema.rb +5 -5
  83. data/lib/acp/types/permission_option.rb +10 -1
  84. data/lib/acp/types/plan.rb +4 -1
  85. data/lib/acp/types/plan_entry.rb +7 -3
  86. data/lib/acp/types/prompt_capabilities.rb +6 -1
  87. data/lib/acp/types/prompt_request.rb +5 -2
  88. data/lib/acp/types/prompt_response.rb +8 -1
  89. data/lib/acp/types/read_text_file_request.rb +4 -4
  90. data/lib/acp/types/read_text_file_response.rb +1 -1
  91. data/lib/acp/types/release_terminal_request.rb +5 -1
  92. data/lib/acp/types/request_permission_outcome/selected.rb +1 -1
  93. data/lib/acp/types/request_permission_request.rb +10 -3
  94. data/lib/acp/types/request_permission_response.rb +8 -1
  95. data/lib/acp/types/resource_link.rb +12 -7
  96. data/lib/acp/types/resume_session_request.rb +14 -4
  97. data/lib/acp/types/resume_session_response.rb +5 -2
  98. data/lib/acp/types/selected_permission_outcome.rb +1 -1
  99. data/lib/acp/types/session_capabilities.rb +20 -7
  100. data/lib/acp/types/session_config_boolean.rb +1 -1
  101. data/lib/acp/types/session_config_option/kind/boolean.rb +1 -1
  102. data/lib/acp/types/session_config_option/kind/select.rb +6 -2
  103. data/lib/acp/types/session_config_option.rb +3 -3
  104. data/lib/acp/types/session_config_options_capabilities.rb +6 -1
  105. data/lib/acp/types/session_config_select.rb +6 -2
  106. data/lib/acp/types/session_config_select_group.rb +6 -3
  107. data/lib/acp/types/session_config_select_option.rb +6 -1
  108. data/lib/acp/types/session_config_select_options.rb +6 -2
  109. data/lib/acp/types/session_info.rb +12 -5
  110. data/lib/acp/types/session_info_update.rb +10 -1
  111. data/lib/acp/types/session_mode.rb +6 -1
  112. data/lib/acp/types/session_mode_state.rb +8 -2
  113. data/lib/acp/types/session_notification.rb +2 -2
  114. data/lib/acp/types/session_update/agent_message_chunk.rb +2 -2
  115. data/lib/acp/types/session_update/agent_thought_chunk.rb +2 -2
  116. data/lib/acp/types/session_update/available_commands_update.rb +7 -4
  117. data/lib/acp/types/session_update/config_option_update.rb +7 -4
  118. data/lib/acp/types/session_update/current_mode_update.rb +4 -1
  119. data/lib/acp/types/session_update/plan.rb +4 -1
  120. data/lib/acp/types/session_update/session_info_update.rb +10 -1
  121. data/lib/acp/types/session_update/tool_call.rb +23 -7
  122. data/lib/acp/types/session_update/tool_call_update.rb +23 -7
  123. data/lib/acp/types/session_update/usage_update.rb +3 -3
  124. data/lib/acp/types/session_update/user_message_chunk.rb +2 -2
  125. data/lib/acp/types/set_session_config_option_request/value/boolean.rb +1 -1
  126. data/lib/acp/types/set_session_config_option_request/value/value_id.rb +1 -1
  127. data/lib/acp/types/set_session_config_option_request.rb +2 -2
  128. data/lib/acp/types/set_session_config_option_response.rb +7 -4
  129. data/lib/acp/types/set_session_mode_request.rb +5 -1
  130. data/lib/acp/types/string_multi_select_items.rb +4 -1
  131. data/lib/acp/types/string_property_schema.rb +15 -9
  132. data/lib/acp/types/terminal.rb +4 -1
  133. data/lib/acp/types/terminal_exit_status.rb +5 -1
  134. data/lib/acp/types/terminal_output_request.rb +5 -1
  135. data/lib/acp/types/terminal_output_response.rb +8 -3
  136. data/lib/acp/types/text_content.rb +7 -2
  137. data/lib/acp/types/text_resource_contents.rb +6 -1
  138. data/lib/acp/types/titled_multi_select_items.rb +4 -1
  139. data/lib/acp/types/tool_call.rb +23 -7
  140. data/lib/acp/types/tool_call_content/content.rb +4 -1
  141. data/lib/acp/types/tool_call_content/diff.rb +6 -1
  142. data/lib/acp/types/tool_call_content/terminal.rb +4 -1
  143. data/lib/acp/types/tool_call_location.rb +5 -1
  144. data/lib/acp/types/tool_call_update.rb +23 -7
  145. data/lib/acp/types/unstructured_command_input.rb +1 -1
  146. data/lib/acp/types/usage_update.rb +3 -3
  147. data/lib/acp/types/wait_for_terminal_exit_request.rb +5 -1
  148. data/lib/acp/types/wait_for_terminal_exit_response.rb +5 -1
  149. data/lib/acp/types/write_text_file_request.rb +3 -3
  150. data/lib/acp/types.rb +247 -0
  151. data/lib/acp/version.rb +1 -1
  152. data/sig/generated/acp/client_connection.rbs +39 -1
  153. data/sig/generated/acp/request_error.rbs +12 -1
  154. data/sig/generated/acp/types.rbs +143 -0
  155. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 978b245a94b3cd60d3858ee42c3deaf9b687c8a6a1701ce54c684a5e434b2bce
4
- data.tar.gz: b83adfe811bc7873455f9f81993061e7b2ea66d0a75b78645807fc44e08cd58b
3
+ metadata.gz: e0b8b181c4cbd41a411868aecdf9f34d0356b490526c446a962287a5a3ae01cc
4
+ data.tar.gz: 1376e6d42bc4369de51a645ed423a563e7aa9b35c4c49b060c77a983cb2c603c
5
5
  SHA512:
6
- metadata.gz: 2907a71594882b7afd3ed8fe89b2e9520f139dc1c235a4e43bf836a2f127ae6264ce948d86885577daa6cd320078c6da831f4b8df89225201287276e17710c4e
7
- data.tar.gz: 9191c0362932d37aaeb55666fb0b892505e448949f13e4173d0d9a0196ccebd16e56324f42db33f6c0d47fbec658bce6aac6e1bec4e4e9d4bdd4d6a1237ffc11
6
+ metadata.gz: 6c5d6271be17f17e203c178664ad6de2234c4b1a7b1857663c1ebb20bdbe1fa3c282db70ac6fe3c3e6b9647dfaadbe761bd018972943393a0cb3dec3d10bb2a3
7
+ data.tar.gz: 9ca3cae203c38496096dd020403054daaa4cb6797183c95b04a75c045f4a8aff89754a07af0fd74eb4c2a45f48dbc6d7c64352faf572000f118130d473283f0b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/bottrall/acp-sdk/compare/v0.7.0...v0.8.0) (2026-10-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * elicitation handlers on ACP::ClientConnection ([#78](https://github.com/bottrall/acp-sdk/issues/78)) ([94ee653](https://github.com/bottrall/acp-sdk/commit/94ee65374a0b463ccbd7ed57e05fe1ad76b08a62))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * validate primitive and enum types when parsing params ([#79](https://github.com/bottrall/acp-sdk/issues/79)) ([624be63](https://github.com/bottrall/acp-sdk/commit/624be63f667468fba7c1a61064b84f43c226b060))
14
+
3
15
  ## [0.7.0](https://github.com/bottrall/acp-sdk/compare/v0.6.0...v0.7.0) (2026-10-05)
4
16
 
5
17
 
data/README.md CHANGED
@@ -49,7 +49,7 @@ connection.start.join
49
49
  The agent's contract:
50
50
 
51
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.
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.
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
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
54
  - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it.
55
55
  - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
@@ -107,6 +107,7 @@ connection = ACP::ClientConnection.new(
107
107
  - `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.
108
108
  - `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.
109
109
  - `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.
110
+ - `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.
110
111
 
111
112
  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`.
112
113
 
@@ -136,7 +136,7 @@ class ACP::AgentConnection::Client
136
136
  return result if result.is_a?(ACP::RequestError)
137
137
 
138
138
  type.from_h(result)
139
- rescue KeyError, TypeError, NoMethodError
139
+ rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError
140
140
  ACP::RequestError.invalid_response(result)
141
141
  end
142
142
 
@@ -117,7 +117,7 @@ class ACP::AgentConnection
117
117
  # @rbs return: void
118
118
  def cancel(agent, params)
119
119
  notification = ACP::Types::CancelNotification.from_h(params)
120
- rescue KeyError, TypeError, NoMethodError => e
120
+ rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
121
121
  @logger.warn("dropped malformed session/cancel: #{e.class}: #{e.message}")
122
122
  else
123
123
  agent.cancel(notification)
@@ -131,6 +131,8 @@ class ACP::AgentConnection
131
131
  def route(type, &)
132
132
  lambda do |params|
133
133
  request = type.from_h(params)
134
+ rescue ACP::Types::ParseError => e
135
+ ACP::RequestError.invalid_params([e.message])
134
136
  rescue KeyError, TypeError, NoMethodError
135
137
  ACP::RequestError.invalid_params
136
138
  else
@@ -11,14 +11,19 @@ class ACP::ClientConnection
11
11
  # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
12
12
  # @rbs @kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
13
13
  # @rbs @release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
14
+ # @rbs @elicitation: (^(ACP::Types::CreateElicitationRequest) ->
15
+ # (ACP::Types::CreateElicitationResponse | ACP::RequestError))?
16
+ # @rbs @complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
14
17
  # @rbs @updates: ACP::ClientConnection::_UpdateHandler
15
18
  # @rbs @fs_capabilities: ACP::Types::FileSystemCapabilities?
16
19
  # @rbs @terminal: bool?
20
+ # @rbs @elicitation_capabilities: ACP::Types::ElicitationCapabilities?
17
21
  # @rbs @extension_requests: Hash[String, ^(untyped) -> untyped]
18
22
  # @rbs @extension_notifications: Hash[String, ^(untyped) -> void]
19
23
  # @rbs @lock: Thread::Mutex
20
24
  # @rbs @streams: Hash[String, Thread::Queue]
21
25
  # @rbs @pending_permissions: Hash[String, Array[Thread::Queue]]
26
+ # @rbs @outstanding_elicitations: Hash[String, bool]
22
27
  # @rbs @logger: ACP::Transport::_Logger
23
28
 
24
29
  PROTOCOL_VERSION = ACP::AgentConnection::PROTOCOL_VERSION #: Integer
@@ -39,6 +44,9 @@ class ACP::ClientConnection
39
44
  # (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError))?
40
45
  # @rbs kill_terminal: (^(ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError))?
41
46
  # @rbs release_terminal: (^(ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError))?
47
+ # @rbs elicitation: (^(ACP::Types::CreateElicitationRequest) ->
48
+ # (ACP::Types::CreateElicitationResponse | ACP::RequestError))?
49
+ # @rbs complete_elicitation: (^(ACP::Types::CompleteElicitationNotification) -> void)?
42
50
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
43
51
  # @rbs extension_requests: Hash[String, ^(untyped) -> untyped]
44
52
  # @rbs extension_notifications: Hash[String, ^(untyped) -> void]
@@ -54,6 +62,8 @@ class ACP::ClientConnection
54
62
  wait_for_terminal_exit: nil,
55
63
  kill_terminal: nil,
56
64
  release_terminal: nil,
65
+ elicitation: nil,
66
+ complete_elicitation: nil,
57
67
  updates: IGNORE,
58
68
  extension_requests: {},
59
69
  extension_notifications: {},
@@ -68,9 +78,12 @@ class ACP::ClientConnection
68
78
  @wait_for_terminal_exit = wait_for_terminal_exit
69
79
  @kill_terminal = kill_terminal
70
80
  @release_terminal = release_terminal
81
+ @elicitation = elicitation
82
+ @complete_elicitation = complete_elicitation
71
83
  @updates = updates
72
84
  @fs_capabilities = nil
73
85
  @terminal = nil
86
+ @elicitation_capabilities = nil
74
87
  @extension_requests = extension_requests
75
88
  @extension_notifications = extension_notifications
76
89
  extension_requests.each_key { |name| ACP::Extensions.validate_name(name) }
@@ -78,6 +91,7 @@ class ACP::ClientConnection
78
91
  @lock = Mutex.new
79
92
  @streams = {}
80
93
  @pending_permissions = {}
94
+ @outstanding_elicitations = {}
81
95
  @logger = logger
82
96
  end
83
97
 
@@ -92,9 +106,11 @@ class ACP::ClientConnection
92
106
  'terminal/output' => method(:terminal_output),
93
107
  'terminal/wait_for_exit' => method(:wait_for_terminal_exit),
94
108
  'terminal/kill' => method(:kill_terminal),
95
- 'terminal/release' => method(:release_terminal)
109
+ 'terminal/release' => method(:release_terminal),
110
+ 'elicitation/create' => method(:create_elicitation)
96
111
  }.merge(@extension_requests),
97
- notifications: { 'session/update' => method(:dispatch) }.merge(@extension_notifications)
112
+ notifications: { 'session/update' => method(:dispatch),
113
+ 'elicitation/complete' => method(:complete_elicitation) }.merge(@extension_notifications)
98
114
  )
99
115
  end
100
116
 
@@ -116,8 +132,15 @@ class ACP::ClientConnection
116
132
  raise ArgumentError, "initialize advertises terminal methods no handler serves: #{unserved.join(', ')}"
117
133
  end
118
134
 
135
+ elicitation = request.client_capabilities&.elicitation
136
+ unserved = unserved_elicitation_modes(elicitation)
137
+ unless unserved.empty?
138
+ raise ArgumentError, "initialize advertises elicitation modes no handler serves: #{unserved.join(', ')}"
139
+ end
140
+
119
141
  @fs_capabilities = capabilities
120
142
  @terminal = terminal
143
+ @elicitation_capabilities = elicitation
121
144
  parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h)).then do |response|
122
145
  next response if response.is_a?(ACP::RequestError)
123
146
 
@@ -246,7 +269,7 @@ class ACP::ClientConnection
246
269
  return result if result.is_a?(ACP::RequestError)
247
270
 
248
271
  type.from_h(result)
249
- rescue KeyError, TypeError, NoMethodError
272
+ rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError
250
273
  ACP::RequestError.invalid_response(result)
251
274
  end
252
275
 
@@ -295,7 +318,7 @@ class ACP::ClientConnection
295
318
  # @rbs return: void
296
319
  def dispatch(params)
297
320
  notification = ACP::Types::SessionNotification.from_h(params)
298
- rescue KeyError, TypeError, NoMethodError => e
321
+ rescue ACP::Types::ParseError, KeyError, TypeError, NoMethodError => e
299
322
  @logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
300
323
  else
301
324
  queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
@@ -310,6 +333,8 @@ class ACP::ClientConnection
310
333
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
311
334
  def request_permission(params)
312
335
  request = ACP::Types::RequestPermissionRequest.from_h(params)
336
+ rescue ACP::Types::ParseError => e
337
+ ACP::RequestError.invalid_params([e.message])
313
338
  rescue KeyError, TypeError, NoMethodError
314
339
  ACP::RequestError.invalid_params
315
340
  else
@@ -363,6 +388,8 @@ class ACP::ClientConnection
363
388
  return ACP::RequestError.unadvertised('fs.readTextFile') unless @fs_capabilities&.read_text_file
364
389
 
365
390
  request = ACP::Types::ReadTextFileRequest.from_h(params)
391
+ rescue ACP::Types::ParseError => e
392
+ ACP::RequestError.invalid_params([e.message])
366
393
  rescue KeyError, TypeError, NoMethodError
367
394
  ACP::RequestError.invalid_params
368
395
  else
@@ -377,6 +404,8 @@ class ACP::ClientConnection
377
404
  return ACP::RequestError.unadvertised('fs.writeTextFile') unless @fs_capabilities&.write_text_file
378
405
 
379
406
  request = ACP::Types::WriteTextFileRequest.from_h(params)
407
+ rescue ACP::Types::ParseError => e
408
+ ACP::RequestError.invalid_params([e.message])
380
409
  rescue KeyError, TypeError, NoMethodError
381
410
  ACP::RequestError.invalid_params
382
411
  else
@@ -461,6 +490,54 @@ class ACP::ClientConnection
461
490
  handler.call(request)
462
491
  end
463
492
 
493
+ # The routes are registered at start, before connect records the advertised
494
+ # capabilities, so a request for an unadvertised mode answers -32602.
495
+ #
496
+ # @rbs params: untyped
497
+ # @rbs return: (ACP::Types::CreateElicitationResponse | ACP::RequestError)
498
+ def create_elicitation(params)
499
+ request = ACP::Types::CreateElicitationRequest.from_h(params)
500
+ rescue KeyError, TypeError, NoMethodError
501
+ ACP::RequestError.invalid_params
502
+ else
503
+ case request.mode
504
+ when ACP::Types::CreateElicitationRequest::Mode::Form
505
+ return ACP::RequestError.unadvertised_mode('elicitation.form') unless @elicitation_capabilities&.form
506
+ when ACP::Types::CreateElicitationRequest::Mode::Url
507
+ return ACP::RequestError.unadvertised_mode('elicitation.url') unless @elicitation_capabilities&.url
508
+
509
+ register_elicitation(request.mode.elicitation_id)
510
+ else
511
+ return ACP::RequestError.unadvertised_mode("elicitation.#{request.mode['mode']}")
512
+ end
513
+
514
+ # Safe: connect refuses an advertised mode no handler serves.
515
+ handler = @elicitation #: ^(ACP::Types::CreateElicitationRequest) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
516
+ handler.call(request)
517
+ end
518
+
519
+ # The spec requires a completion for an unknown or already-completed
520
+ # elicitation id to be ignored, so only an id registered by a served
521
+ # url-mode request reaches the handler. It runs on the transport's reader
522
+ # thread, so it must return quickly.
523
+ #
524
+ # @rbs params: untyped
525
+ # @rbs return: void
526
+ def complete_elicitation(params)
527
+ notification = ACP::Types::CompleteElicitationNotification.from_h(params)
528
+ rescue KeyError, TypeError, NoMethodError => e
529
+ @logger.warn("dropped malformed elicitation/complete: #{e.class}: #{e.message}")
530
+ else
531
+ known = @lock.synchronize { @outstanding_elicitations.delete(notification.elicitation_id) }
532
+ @complete_elicitation&.call(notification) if known
533
+ end
534
+
535
+ # @rbs elicitation_id: String
536
+ # @rbs return: void
537
+ def register_elicitation(elicitation_id)
538
+ @lock.synchronize { @outstanding_elicitations[elicitation_id] = true }
539
+ end
540
+
464
541
  # The fs capabilities initialize advertises that no injected handler serves.
465
542
  #
466
543
  # @rbs capabilities: ACP::Types::FileSystemCapabilities?
@@ -486,4 +563,16 @@ class ACP::ClientConnection
486
563
  'terminal.release' => capabilities && !@release_terminal
487
564
  }.select { |_, unserved| unserved }.keys
488
565
  end
566
+
567
+ # The elicitation capabilities initialize advertises that no injected
568
+ # handler serves.
569
+ #
570
+ # @rbs capabilities: ACP::Types::ElicitationCapabilities?
571
+ # @rbs return: Array[String]
572
+ def unserved_elicitation_modes(capabilities)
573
+ {
574
+ 'elicitation.form' => capabilities&.form && !@elicitation,
575
+ 'elicitation.url' => capabilities&.url && !@elicitation
576
+ }.select { |_, unserved| unserved }.keys
577
+ end
489
578
  end
@@ -54,9 +54,10 @@ class ACP::RequestError
54
54
  new(code: METHOD_NOT_FOUND, message: 'Method not found', data: method && { method: method })
55
55
  end
56
56
 
57
+ # @rbs errors: Array[String]?
57
58
  # @rbs return: ACP::RequestError
58
- def self.invalid_params
59
- new(code: INVALID_PARAMS, message: 'Invalid params')
59
+ def self.invalid_params(errors = nil)
60
+ new(code: INVALID_PARAMS, message: 'Invalid params', data: errors && { errors: errors })
60
61
  end
61
62
 
62
63
  # The method-not-found code for a method the client's capabilities do not
@@ -69,6 +70,18 @@ class ACP::RequestError
69
70
  new(code: METHOD_NOT_FOUND, message: "Client does not advertise #{capability}")
70
71
  end
71
72
 
73
+ # The invalid-params code for a request whose mode the client's
74
+ # capabilities do not advertise, with a message that names the mode so it
75
+ # can be told apart from malformed params. The spec gives elicitation a
76
+ # mode in its params, so an unadvertised one is invalid params rather than
77
+ # method not found.
78
+ #
79
+ # @rbs mode: String
80
+ # @rbs return: ACP::RequestError
81
+ def self.unadvertised_mode(mode)
82
+ new(code: INVALID_PARAMS, message: "Client does not advertise #{mode}")
83
+ end
84
+
72
85
  # @rbs return: ACP::RequestError
73
86
  def self.internal_error
74
87
  new(code: INTERNAL_ERROR, message: 'Internal error')
@@ -21,7 +21,10 @@ class ACP::Types::AgentAuthCapabilities
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::AgentAuthCapabilities
23
23
  def self.from_h(hash)
24
- new(logout: hash['logout']&.then { |value| ACP::Types::LogoutCapabilities.from_h(value) }, meta: hash['_meta'])
24
+ new(
25
+ logout: ACP::Types::Check.object(hash['logout'], 'logout', ACP::Types::LogoutCapabilities, allow_nil: true),
26
+ meta: hash['_meta']
27
+ )
25
28
  end
26
29
 
27
30
  # @rbs return: Hash[String, untyped]
@@ -49,11 +49,26 @@ class ACP::Types::AgentCapabilities
49
49
  # @rbs return: ACP::Types::AgentCapabilities
50
50
  def self.from_h(hash)
51
51
  new(
52
- load_session: hash['loadSession'],
53
- prompt_capabilities: hash['promptCapabilities']&.then { |value| ACP::Types::PromptCapabilities.from_h(value) },
54
- mcp_capabilities: hash['mcpCapabilities']&.then { |value| ACP::Types::McpCapabilities.from_h(value) },
55
- session_capabilities: hash['sessionCapabilities']&.then { |value| ACP::Types::SessionCapabilities.from_h(value) },
56
- auth: hash['auth']&.then { |value| ACP::Types::AgentAuthCapabilities.from_h(value) },
52
+ load_session: ACP::Types::Check.boolean(hash['loadSession'], 'loadSession', allow_nil: true),
53
+ prompt_capabilities: ACP::Types::Check.object(
54
+ hash['promptCapabilities'],
55
+ 'promptCapabilities',
56
+ ACP::Types::PromptCapabilities,
57
+ allow_nil: true
58
+ ),
59
+ mcp_capabilities: ACP::Types::Check.object(
60
+ hash['mcpCapabilities'],
61
+ 'mcpCapabilities',
62
+ ACP::Types::McpCapabilities,
63
+ allow_nil: true
64
+ ),
65
+ session_capabilities: ACP::Types::Check.object(
66
+ hash['sessionCapabilities'],
67
+ 'sessionCapabilities',
68
+ ACP::Types::SessionCapabilities,
69
+ allow_nil: true
70
+ ),
71
+ auth: ACP::Types::Check.object(hash['auth'], 'auth', ACP::Types::AgentAuthCapabilities, allow_nil: true),
57
72
  meta: hash['_meta']
58
73
  )
59
74
  end
@@ -31,10 +31,13 @@ class ACP::Types::Annotations
31
31
  # @rbs hash: Hash[String, untyped]
32
32
  # @rbs return: ACP::Types::Annotations
33
33
  def self.from_h(hash)
34
+ audience = ACP::Types::Check.array(hash['audience'], 'audience', allow_nil: true) do |item, path|
35
+ ACP::Types::Check.enum(item, path, %w[assistant user])
36
+ end
34
37
  new(
35
- audience: hash['audience'],
36
- last_modified: hash['lastModified'],
37
- priority: hash['priority'],
38
+ audience:,
39
+ last_modified: ACP::Types::Check.string(hash['lastModified'], 'lastModified', allow_nil: true),
40
+ priority: ACP::Types::Check.number(hash['priority'], 'priority', allow_nil: true),
38
41
  meta: hash['_meta']
39
42
  )
40
43
  end
@@ -32,9 +32,14 @@ class ACP::Types::AudioContent
32
32
  # @rbs return: ACP::Types::AudioContent
33
33
  def self.from_h(hash)
34
34
  new(
35
- annotations: hash['annotations']&.then { |value| ACP::Types::Annotations.from_h(value) },
36
- data: hash.fetch('data'),
37
- mime_type: hash.fetch('mimeType'),
35
+ annotations: ACP::Types::Check.object(
36
+ hash['annotations'],
37
+ 'annotations',
38
+ ACP::Types::Annotations,
39
+ allow_nil: true
40
+ ),
41
+ data: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'data'), 'data'),
42
+ mime_type: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'mimeType'), 'mimeType'),
38
43
  meta: hash['_meta']
39
44
  )
40
45
  end
@@ -21,7 +21,7 @@ class ACP::Types::AuthCapabilities
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::AuthCapabilities
23
23
  def self.from_h(hash)
24
- new(terminal: hash['terminal'], meta: hash['_meta'])
24
+ new(terminal: ACP::Types::Check.boolean(hash['terminal'], 'terminal', allow_nil: true), meta: hash['_meta'])
25
25
  end
26
26
 
27
27
  # @rbs return: Hash[String, untyped]
@@ -41,12 +41,18 @@ class ACP::Types::AuthMethod::Terminal
41
41
  # @rbs hash: Hash[String, untyped]
42
42
  # @rbs return: ACP::Types::AuthMethod::Terminal
43
43
  def self.from_h(hash)
44
+ args = ACP::Types::Check.array(hash['args'], 'args', allow_nil: true) do |item, path|
45
+ ACP::Types::Check.string(item, path)
46
+ end
47
+ env = ACP::Types::Check.hash(hash['env'], 'env', allow_nil: true) do |value, path|
48
+ ACP::Types::Check.string(value, path)
49
+ end
44
50
  new(
45
- id: hash.fetch('id'),
46
- name: hash.fetch('name'),
47
- description: hash['description'],
48
- args: hash['args'],
49
- env: hash['env'],
51
+ id: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'id'), 'id'),
52
+ name: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'name'), 'name'),
53
+ description: ACP::Types::Check.string(hash['description'], 'description', allow_nil: true),
54
+ args:,
55
+ env:,
50
56
  meta: hash['_meta']
51
57
  )
52
58
  end
@@ -31,7 +31,12 @@ class ACP::Types::AuthMethodAgent
31
31
  # @rbs hash: Hash[String, untyped]
32
32
  # @rbs return: ACP::Types::AuthMethodAgent
33
33
  def self.from_h(hash)
34
- new(id: hash.fetch('id'), name: hash.fetch('name'), description: hash['description'], meta: hash['_meta'])
34
+ new(
35
+ id: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'id'), 'id'),
36
+ name: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'name'), 'name'),
37
+ description: ACP::Types::Check.string(hash['description'], 'description', allow_nil: true),
38
+ meta: hash['_meta']
39
+ )
35
40
  end
36
41
 
37
42
  # @rbs return: Hash[String, untyped]
@@ -41,12 +41,18 @@ class ACP::Types::AuthMethodTerminal
41
41
  # @rbs hash: Hash[String, untyped]
42
42
  # @rbs return: ACP::Types::AuthMethodTerminal
43
43
  def self.from_h(hash)
44
+ args = ACP::Types::Check.array(hash['args'], 'args', allow_nil: true) do |item, path|
45
+ ACP::Types::Check.string(item, path)
46
+ end
47
+ env = ACP::Types::Check.hash(hash['env'], 'env', allow_nil: true) do |value, path|
48
+ ACP::Types::Check.string(value, path)
49
+ end
44
50
  new(
45
- id: hash.fetch('id'),
46
- name: hash.fetch('name'),
47
- description: hash['description'],
48
- args: hash['args'],
49
- env: hash['env'],
51
+ id: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'id'), 'id'),
52
+ name: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'name'), 'name'),
53
+ description: ACP::Types::Check.string(hash['description'], 'description', allow_nil: true),
54
+ args:,
55
+ env:,
50
56
  meta: hash['_meta']
51
57
  )
52
58
  end
@@ -21,7 +21,7 @@ class ACP::Types::AuthenticateRequest
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::AuthenticateRequest
23
23
  def self.from_h(hash)
24
- new(method_id: hash.fetch('methodId'), meta: hash['_meta'])
24
+ new(method_id: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'methodId'), 'methodId'), meta: hash['_meta'])
25
25
  end
26
26
 
27
27
  # @rbs return: Hash[String, untyped]
@@ -32,9 +32,9 @@ class ACP::Types::AvailableCommand
32
32
  # @rbs return: ACP::Types::AvailableCommand
33
33
  def self.from_h(hash)
34
34
  new(
35
- name: hash.fetch('name'),
36
- description: hash.fetch('description'),
37
- input: hash['input']&.then { |value| ACP::Types::AvailableCommandInput.from_h(value) },
35
+ name: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'name'), 'name'),
36
+ description: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'description'), 'description'),
37
+ input: ACP::Types::Check.object(hash['input'], 'input', ACP::Types::AvailableCommandInput, allow_nil: true),
38
38
  meta: hash['_meta']
39
39
  )
40
40
  end
@@ -21,10 +21,13 @@ class ACP::Types::AvailableCommandsUpdate
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::AvailableCommandsUpdate
23
23
  def self.from_h(hash)
24
- new(
25
- available_commands: hash.fetch('availableCommands').map { |item| ACP::Types::AvailableCommand.from_h(item) },
26
- meta: hash['_meta']
27
- )
24
+ available_commands = ACP::Types::Check.array(
25
+ ACP::Types::Check.key(hash, 'availableCommands'),
26
+ 'availableCommands'
27
+ ) do |item, path|
28
+ ACP::Types::Check.object(item, path, ACP::Types::AvailableCommand)
29
+ end
30
+ new(available_commands:, meta: hash['_meta'])
28
31
  end
29
32
 
30
33
  # @rbs return: Hash[String, untyped]
@@ -31,7 +31,12 @@ class ACP::Types::BlobResourceContents
31
31
  # @rbs hash: Hash[String, untyped]
32
32
  # @rbs return: ACP::Types::BlobResourceContents
33
33
  def self.from_h(hash)
34
- new(blob: hash.fetch('blob'), mime_type: hash['mimeType'], uri: hash.fetch('uri'), meta: hash['_meta'])
34
+ new(
35
+ blob: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'blob'), 'blob'),
36
+ mime_type: ACP::Types::Check.string(hash['mimeType'], 'mimeType', allow_nil: true),
37
+ uri: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'uri'), 'uri'),
38
+ meta: hash['_meta']
39
+ )
35
40
  end
36
41
 
37
42
  # @rbs return: Hash[String, untyped]
@@ -31,7 +31,12 @@ class ACP::Types::BooleanPropertySchema
31
31
  # @rbs hash: Hash[String, untyped]
32
32
  # @rbs return: ACP::Types::BooleanPropertySchema
33
33
  def self.from_h(hash)
34
- new(title: hash['title'], description: hash['description'], default: hash['default'], meta: hash['_meta'])
34
+ new(
35
+ title: ACP::Types::Check.string(hash['title'], 'title', allow_nil: true),
36
+ description: ACP::Types::Check.string(hash['description'], 'description', allow_nil: true),
37
+ default: ACP::Types::Check.boolean(hash['default'], 'default', allow_nil: true),
38
+ meta: hash['_meta']
39
+ )
35
40
  end
36
41
 
37
42
  # @rbs return: Hash[String, untyped]
@@ -21,7 +21,10 @@ class ACP::Types::CancelNotification
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::CancelNotification
23
23
  def self.from_h(hash)
24
- new(session_id: hash.fetch('sessionId'), meta: hash['_meta'])
24
+ new(
25
+ session_id: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'sessionId'), 'sessionId'),
26
+ meta: hash['_meta']
27
+ )
25
28
  end
26
29
 
27
30
  # @rbs return: Hash[String, untyped]
@@ -21,7 +21,15 @@ class ACP::Types::CancelRequestNotification
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::CancelRequestNotification
23
23
  def self.from_h(hash)
24
- new(request_id: hash.fetch('requestId'), meta: hash['_meta'])
24
+ new(
25
+ request_id: ACP::Types::Check.one_of(
26
+ ACP::Types::Check.key(hash, 'requestId'),
27
+ 'requestId',
28
+ [Integer, String],
29
+ allow_nil: true
30
+ ),
31
+ meta: hash['_meta']
32
+ )
25
33
  end
26
34
 
27
35
  # @rbs return: Hash[String, untyped]
@@ -42,11 +42,21 @@ class ACP::Types::ClientCapabilities
42
42
  # @rbs return: ACP::Types::ClientCapabilities
43
43
  def self.from_h(hash)
44
44
  new(
45
- fs: hash['fs']&.then { |value| ACP::Types::FileSystemCapabilities.from_h(value) },
46
- terminal: hash['terminal'],
47
- session: hash['session']&.then { |value| ACP::Types::ClientSessionCapabilities.from_h(value) },
48
- auth: hash['auth']&.then { |value| ACP::Types::AuthCapabilities.from_h(value) },
49
- elicitation: hash['elicitation']&.then { |value| ACP::Types::ElicitationCapabilities.from_h(value) },
45
+ fs: ACP::Types::Check.object(hash['fs'], 'fs', ACP::Types::FileSystemCapabilities, allow_nil: true),
46
+ terminal: ACP::Types::Check.boolean(hash['terminal'], 'terminal', allow_nil: true),
47
+ session: ACP::Types::Check.object(
48
+ hash['session'],
49
+ 'session',
50
+ ACP::Types::ClientSessionCapabilities,
51
+ allow_nil: true
52
+ ),
53
+ auth: ACP::Types::Check.object(hash['auth'], 'auth', ACP::Types::AuthCapabilities, allow_nil: true),
54
+ elicitation: ACP::Types::Check.object(
55
+ hash['elicitation'],
56
+ 'elicitation',
57
+ ACP::Types::ElicitationCapabilities,
58
+ allow_nil: true
59
+ ),
50
60
  meta: hash['_meta']
51
61
  )
52
62
  end
@@ -22,9 +22,12 @@ class ACP::Types::ClientSessionCapabilities
22
22
  # @rbs return: ACP::Types::ClientSessionCapabilities
23
23
  def self.from_h(hash)
24
24
  new(
25
- config_options: hash['configOptions']&.then do |value|
26
- ACP::Types::SessionConfigOptionsCapabilities.from_h(value)
27
- end,
25
+ config_options: ACP::Types::Check.object(
26
+ hash['configOptions'],
27
+ 'configOptions',
28
+ ACP::Types::SessionConfigOptionsCapabilities,
29
+ allow_nil: true
30
+ ),
28
31
  meta: hash['_meta']
29
32
  )
30
33
  end
@@ -21,7 +21,10 @@ class ACP::Types::CloseSessionRequest
21
21
  # @rbs hash: Hash[String, untyped]
22
22
  # @rbs return: ACP::Types::CloseSessionRequest
23
23
  def self.from_h(hash)
24
- new(session_id: hash.fetch('sessionId'), meta: hash['_meta'])
24
+ new(
25
+ session_id: ACP::Types::Check.string(ACP::Types::Check.key(hash, 'sessionId'), 'sessionId'),
26
+ meta: hash['_meta']
27
+ )
25
28
  end
26
29
 
27
30
  # @rbs return: Hash[String, untyped]