acp-sdk 0.3.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59dca9a568751a3b4c29c1ff4afdd9ad12e997689d5eb3a4782f2a4c5ced81b0
4
- data.tar.gz: 99bde6bbc81d16012a6d9b3654bf15f15a35a40713f3d6dbbe686557f182ac50
3
+ metadata.gz: 77ac970489b956c7216d4c94a8d5cc6e7bf3fe5bfd10110cb8558c119f58f69d
4
+ data.tar.gz: 29ec919463eb48d06811aa8b6c736163fd7872348ce99f3f8cdc10c5670bf8c4
5
5
  SHA512:
6
- metadata.gz: f803ca1e9d3dae3b791cebe930d49c221b2cd3f68df6cc5734bc3e40103af0d101db4447851596c2f6834b855f08a110c24455b06c517aa62e51b95a23cbdfd2
7
- data.tar.gz: 844f53d23060359a77314f196fdeab023a7ded7c67fdea9f1a75b80bb28db3efb2421977001c13b3ec37f84479f6b9bf816e53a4a8da92c89bc58ee211b88ab8
6
+ metadata.gz: acf89beeb444ff33ac273aececd55d645792fe591f346cbdce0a099b6b6bff4eedb4025cceadd3a46ec62253c85b111c0149466960707b3feaa92623dab600a8
7
+ data.tar.gz: 92fba44ccd9334876fa8cb689728a7a19cc3d473bf1e7ee27fe215d72e48952e04bc9c0428609e31e883bdef6986e1ec7b4c9383d8a37271646593b33ff069bd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/bottrall/acp-sdk/compare/v0.4.0...v0.5.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * add authenticate and logout to ACP::ClientConnection ([#63](https://github.com/bottrall/acp-sdk/issues/63)) ([460585a](https://github.com/bottrall/acp-sdk/commit/460585a9a9401e35725ea379432694d9b76f35b6))
9
+ * add session_set_mode and session_set_config_option to ACP::ClientConnection ([#66](https://github.com/bottrall/acp-sdk/issues/66)) ([994179f](https://github.com/bottrall/acp-sdk/commit/994179f9ab51918bee7d29c8b473e0f05a55a7ad))
10
+ * add session/resume, session/close and session/delete to the client ([#65](https://github.com/bottrall/acp-sdk/issues/65)) ([4af834a](https://github.com/bottrall/acp-sdk/commit/4af834a5bc519753bad36d41efba902bcddc296b)), closes [#37](https://github.com/bottrall/acp-sdk/issues/37)
11
+ * elicitation on ACP::AgentConnection::Client ([#67](https://github.com/bottrall/acp-sdk/issues/67)) ([7d3212a](https://github.com/bottrall/acp-sdk/commit/7d3212a82ec6392351d8df1349b2fc21bf0bf030)), closes [#41](https://github.com/bottrall/acp-sdk/issues/41)
12
+ * filesystem handlers on ACP::ClientConnection ([#68](https://github.com/bottrall/acp-sdk/issues/68)) ([053f091](https://github.com/bottrall/acp-sdk/commit/053f0910adc3a7016bf1769099a54655f41bb9a6))
13
+ * injectable logger for dropped messages and swallowed errors ([#59](https://github.com/bottrall/acp-sdk/issues/59)) ([e66a306](https://github.com/bottrall/acp-sdk/commit/e66a30622922717d1be6d27c5b9a2931643fcd6b))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * call ACP::RequestError.unadvertised in elicitation capability checks ([#69](https://github.com/bottrall/acp-sdk/issues/69)) ([fda6dad](https://github.com/bottrall/acp-sdk/commit/fda6dad13e7fe308b5ff63e4c9e6fa9eef6e8274))
19
+ * only advertise terminal auth methods to clients that support them ([#61](https://github.com/bottrall/acp-sdk/issues/61)) ([1d2674a](https://github.com/bottrall/acp-sdk/commit/1d2674a978d938834d69d2c68df4f2c918ea801c)), closes [#33](https://github.com/bottrall/acp-sdk/issues/33)
20
+ * refuse overlapping streaming calls for the same session ([#64](https://github.com/bottrall/acp-sdk/issues/64)) ([a3a5b34](https://github.com/bottrall/acp-sdk/commit/a3a5b343f0ba2555b62643577226166fada27485))
21
+ * reject an unsupported protocol version in ACP::ClientConnection#connect ([#62](https://github.com/bottrall/acp-sdk/issues/62)) ([6980b32](https://github.com/bottrall/acp-sdk/commit/6980b328bcec997ae68c6af9077d623a73c15d39)), closes [#34](https://github.com/bottrall/acp-sdk/issues/34)
22
+
23
+ ## [0.4.0](https://github.com/bottrall/acp-sdk/compare/v0.3.0...v0.4.0) (2026-10-02)
24
+
25
+
26
+ ### Features
27
+
28
+ * bump the vendored schema to v1.24.1 ([#58](https://github.com/bottrall/acp-sdk/issues/58)) ([56d8505](https://github.com/bottrall/acp-sdk/commit/56d85057a1c34f1a089580a78b7b66973c92a9e8))
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * return a RequestError for malformed peer responses ([#57](https://github.com/bottrall/acp-sdk/issues/57)) ([e52d479](https://github.com/bottrall/acp-sdk/commit/e52d479ce8cd6de37f753e7580d5562f1ace2d7d)), closes [#27](https://github.com/bottrall/acp-sdk/issues/27)
34
+ * route logout on ACP::AgentConnection ([#55](https://github.com/bottrall/acp-sdk/issues/55)) ([3b2b144](https://github.com/bottrall/acp-sdk/commit/3b2b1444c502380a058231884c66efe26bb3770a)), closes [#26](https://github.com/bottrall/acp-sdk/issues/26)
35
+
3
36
  ## [0.3.0](https://github.com/bottrall/acp-sdk/compare/v0.2.0...v0.3.0) (2026-10-02)
4
37
 
5
38
 
data/README.md CHANGED
@@ -48,19 +48,20 @@ connection.start.join
48
48
 
49
49
  The agent's contract:
50
50
 
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; `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.
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
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.
53
53
  - `cancel` runs on the transport's reader thread, so it must return quickly: set a flag and let the prompt notice it.
54
54
  - After a cancel, the agent must itself end the turn with `stopReason: cancelled`. `ACP::AgentConnection` does not enforce it.
55
55
  - `client.capabilities` is `nil` until the client sends `initialize`.
56
56
  - `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.
57
57
  - `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.
58
+ - `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.
58
59
 
59
- [`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` and `session/list`.
60
+ [`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`.
60
61
 
61
62
  ## Driving an agent
62
63
 
63
- `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.
64
+ `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.
64
65
 
65
66
  `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.
66
67
 
@@ -90,12 +91,18 @@ Any transport over a pair of IOs works too, so the connection can still be wired
90
91
  connection = ACP::ClientConnection.new(
91
92
  transport: ACP::Transport::Stdio.new(input: some_io, output: other_io),
92
93
  permission: ->(request) { ask_the_user(request) },
94
+ read_text_file: ->(request) { ACP::Types::ReadTextFileResponse.new(content: File.read(request.path)) },
95
+ write_text_file: ->(request) {
96
+ File.write(request.path, request.content)
97
+ ACP::Types::WriteTextFileResponse.new
98
+ },
93
99
  updates: ->(notification) { show(notification) }
94
100
  )
95
101
  ```
96
102
 
97
- - `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.
103
+ - `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.
98
104
  - `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.
105
+ - `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.
99
106
 
100
107
  ## Contributing
101
108
 
@@ -39,7 +39,7 @@ class ACP::AgentConnection::Client
39
39
  # @rbs request: ACP::Types::ReadTextFileRequest
40
40
  # @rbs return: ACP::Types::ReadTextFileResponse | ACP::RequestError
41
41
  def read_text_file(request)
42
- return unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
42
+ return ACP::RequestError.unadvertised('fs.readTextFile') unless capabilities&.fs&.read_text_file
43
43
 
44
44
  call('fs/read_text_file', ACP::Types::ReadTextFileResponse, request)
45
45
  end
@@ -47,7 +47,7 @@ class ACP::AgentConnection::Client
47
47
  # @rbs request: ACP::Types::WriteTextFileRequest
48
48
  # @rbs return: ACP::Types::WriteTextFileResponse | ACP::RequestError
49
49
  def write_text_file(request)
50
- return unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
50
+ return ACP::RequestError.unadvertised('fs.writeTextFile') unless capabilities&.fs&.write_text_file
51
51
 
52
52
  call('fs/write_text_file', ACP::Types::WriteTextFileResponse, request)
53
53
  end
@@ -82,6 +82,29 @@ class ACP::AgentConnection::Client
82
82
  terminal_call('terminal/release', ACP::Types::ReleaseTerminalResponse, request)
83
83
  end
84
84
 
85
+ # @rbs request: ACP::Types::CreateElicitationRequest
86
+ # @rbs return: ACP::Types::CreateElicitationResponse | ACP::RequestError
87
+ def create_elicitation(request)
88
+ case request.mode
89
+ when ACP::Types::CreateElicitationRequest::Mode::Form
90
+ return ACP::RequestError.unadvertised('elicitation.form') unless capabilities&.elicitation&.form
91
+ when ACP::Types::CreateElicitationRequest::Mode::Url
92
+ return ACP::RequestError.unadvertised('elicitation.url') unless capabilities&.elicitation&.url
93
+ else
94
+ return ACP::RequestError.unadvertised("elicitation.#{request.mode['mode']}")
95
+ end
96
+
97
+ call('elicitation/create', ACP::Types::CreateElicitationResponse, request)
98
+ end
99
+
100
+ # @rbs notification: ACP::Types::CompleteElicitationNotification
101
+ # @rbs return: void
102
+ def complete_elicitation(notification)
103
+ return ACP::RequestError.unadvertised('elicitation.url') unless capabilities&.elicitation&.url
104
+
105
+ @peer.notify('elicitation/complete', notification.to_h)
106
+ end
107
+
85
108
  private
86
109
 
87
110
  # @rbs rpc_method: String
@@ -93,6 +116,8 @@ class ACP::AgentConnection::Client
93
116
  return result if result.is_a?(ACP::RequestError)
94
117
 
95
118
  type.from_h(result)
119
+ rescue KeyError, TypeError, NoMethodError
120
+ ACP::RequestError.invalid_response(result)
96
121
  end
97
122
 
98
123
  # @rbs rpc_method: String
@@ -100,19 +125,8 @@ class ACP::AgentConnection::Client
100
125
  # @rbs request: ACP::AgentConnection::_Response
101
126
  # @rbs return: untyped
102
127
  def terminal_call(rpc_method, type, request)
103
- return unadvertised('terminal') unless capabilities&.terminal
128
+ return ACP::RequestError.unadvertised('terminal') unless capabilities&.terminal
104
129
 
105
130
  call(rpc_method, type, request)
106
131
  end
107
-
108
- # Same method-not-found code a peer would send, but with a message that says
109
- # the refusal was local, so logs can tell the two cases apart.
110
- #
111
- # @rbs capability: String
112
- # @rbs return: ACP::RequestError
113
- def unadvertised(capability)
114
- ACP::RequestError.new(
115
- code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
116
- )
117
- end
118
132
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ACP::AgentConnection::OptionalMethod::Logout
4
+ extend self
5
+
6
+ # @rbs return: String
7
+ def rpc_method
8
+ 'logout'
9
+ end
10
+
11
+ # @rbs return: Symbol
12
+ def agent_method
13
+ :logout
14
+ end
15
+
16
+ # @rbs initialize_response: ACP::Types::InitializeResponse
17
+ # @rbs return: bool
18
+ def advertised?(initialize_response)
19
+ initialize_response.agent_capabilities&.auth&.logout ? true : false
20
+ end
21
+ end
@@ -2,8 +2,11 @@
2
2
 
3
3
  class ACP::AgentConnection
4
4
  # @rbs @transport: ACP::AgentConnection::_Transport
5
- # @rbs @initialize_response: ACP::Types::InitializeResponse
5
+ # @rbs @capabilities: ACP::Types::AgentCapabilities
6
+ # @rbs @agent_info: ACP::Types::Implementation?
7
+ # @rbs @auth_methods: Array[ACP::Types::AuthMethod::t]
6
8
  # @rbs @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
9
+ # @rbs @logger: ACP::Transport::_Logger
7
10
 
8
11
  PROTOCOL_VERSION = 1 #: Integer
9
12
  OPTIONAL = [
@@ -12,7 +15,8 @@ class ACP::AgentConnection
12
15
  ACP::AgentConnection::OptionalMethod::Authenticate,
13
16
  ACP::AgentConnection::OptionalMethod::ResumeSession,
14
17
  ACP::AgentConnection::OptionalMethod::CloseSession,
15
- ACP::AgentConnection::OptionalMethod::DeleteSession
18
+ ACP::AgentConnection::OptionalMethod::DeleteSession,
19
+ ACP::AgentConnection::OptionalMethod::Logout
16
20
  ].freeze #: Array[ACP::AgentConnection::_OptionalMethod]
17
21
  # The schema has no initialize capability for modes or config options: an
18
22
  # agent offers them per session, in its session responses, so they are
@@ -26,16 +30,22 @@ class ACP::AgentConnection
26
30
  # @rbs capabilities: ACP::Types::AgentCapabilities
27
31
  # @rbs agent_info: ACP::Types::Implementation?
28
32
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
33
+ # @rbs logger: ACP::Transport::_Logger
29
34
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
30
35
  # @rbs return: void
31
- def initialize(transport:, capabilities:, agent_info: nil, auth_methods: [], &factory)
36
+ def initialize(
37
+ transport:,
38
+ capabilities:,
39
+ agent_info: nil,
40
+ auth_methods: [],
41
+ logger: ACP::Transport::StderrLogger.new,
42
+ &factory
43
+ )
32
44
  @transport = transport
33
- @initialize_response = ACP::Types::InitializeResponse.new(
34
- protocol_version: PROTOCOL_VERSION,
35
- agent_capabilities: capabilities,
36
- auth_methods:,
37
- agent_info:
38
- )
45
+ @logger = logger
46
+ @capabilities = capabilities
47
+ @agent_info = agent_info
48
+ @auth_methods = auth_methods
39
49
  @factory = factory
40
50
  end
41
51
 
@@ -43,7 +53,10 @@ class ACP::AgentConnection
43
53
  def start
44
54
  client = ACP::AgentConnection::Client.new(peer: @transport)
45
55
  agent = @factory.call(client)
46
- advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(@initialize_response) }
56
+ # Routes are fixed before a client connects, so the check sees the
57
+ # unfiltered auth methods: a terminal-capable client still needs
58
+ # authenticate routed.
59
+ advertised, unadvertised = OPTIONAL.partition { |method| method.advertised?(initialize_response(@auth_methods)) }
47
60
  missing = advertised.map(&:agent_method).reject { |name| agent.respond_to?(name) }
48
61
  raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
49
62
 
@@ -70,6 +83,7 @@ class ACP::AgentConnection
70
83
  'session/resume' => route(ACP::Types::ResumeSessionRequest) { |request| full.resume_session(request) },
71
84
  'session/close' => route(ACP::Types::CloseSessionRequest) { |request| full.close_session(request) },
72
85
  'session/delete' => route(ACP::Types::DeleteSessionRequest) { |request| full.delete_session(request) },
86
+ 'logout' => route(ACP::Types::LogoutRequest) { |request| full.logout(request) },
73
87
  'session/set_mode' => route(ACP::Types::SetSessionModeRequest) { |request| full.change_session_mode(request) },
74
88
  'session/set_config_option' => route(ACP::Types::SetSessionConfigOptionRequest) do |request|
75
89
  full.change_session_config_option(request)
@@ -80,7 +94,20 @@ class ACP::AgentConnection
80
94
  # @rbs agent: ACP::AgentConnection::_Agent
81
95
  # @rbs return: Hash[String, ^(untyped) -> void]
82
96
  def notifications(agent)
83
- { 'session/cancel' => ->(params) { agent.cancel(ACP::Types::CancelNotification.from_h(params)) } }
97
+ { 'session/cancel' => ->(params) { cancel(agent, params) } }
98
+ end
99
+
100
+ # A notification has no reply to carry a parse failure.
101
+ #
102
+ # @rbs agent: ACP::AgentConnection::_Agent
103
+ # @rbs params: untyped
104
+ # @rbs return: void
105
+ def cancel(agent, params)
106
+ notification = ACP::Types::CancelNotification.from_h(params)
107
+ rescue KeyError, TypeError, NoMethodError => e
108
+ @logger.warn("dropped malformed session/cancel: #{e.class}: #{e.message}")
109
+ else
110
+ agent.cancel(notification)
84
111
  end
85
112
 
86
113
  # Generated from_h raises on a missing key or a value of the wrong shape.
@@ -102,8 +129,34 @@ class ACP::AgentConnection
102
129
  # @rbs request: ACP::Types::InitializeRequest
103
130
  # @rbs return: ACP::Types::InitializeResponse
104
131
  def connect(client, request)
105
- client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
106
- @initialize_response
132
+ capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
133
+ client.capabilities = capabilities
134
+ initialize_response(auth_methods(capabilities))
135
+ end
136
+
137
+ # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
138
+ # @rbs return: ACP::Types::InitializeResponse
139
+ def initialize_response(auth_methods)
140
+ ACP::Types::InitializeResponse.new(
141
+ protocol_version: PROTOCOL_VERSION,
142
+ agent_capabilities: @capabilities,
143
+ auth_methods:,
144
+ agent_info: @agent_info
145
+ )
146
+ end
147
+
148
+ # @rbs capabilities: ACP::Types::ClientCapabilities
149
+ # @rbs return: Array[ACP::Types::AuthMethod::t]
150
+ def auth_methods(capabilities)
151
+ return @auth_methods if capabilities.auth&.terminal
152
+
153
+ @auth_methods.reject { |method| terminal_auth_method?(method) }
154
+ end
155
+
156
+ # @rbs method: ACP::Types::AuthMethod::t
157
+ # @rbs return: bool
158
+ def terminal_auth_method?(method)
159
+ method.is_a?(ACP::Types::AuthMethod::Terminal) || (method.is_a?(Hash) && method['type'] == 'terminal')
107
160
  end
108
161
 
109
162
  # session_created runs after the reply because the client must know the
@@ -3,28 +3,45 @@
3
3
  class ACP::ClientConnection
4
4
  # @rbs @transport: ACP::AgentConnection::_Transport
5
5
  # @rbs @permission: ACP::ClientConnection::_PermissionHandler
6
+ # @rbs @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
7
+ # @rbs @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
6
8
  # @rbs @updates: ACP::ClientConnection::_UpdateHandler
9
+ # @rbs @fs_capabilities: ACP::Types::FileSystemCapabilities?
7
10
  # @rbs @lock: Thread::Mutex
8
11
  # @rbs @streams: Hash[String, Thread::Queue]
12
+ # @rbs @logger: ACP::Transport::_Logger
13
+
14
+ PROTOCOL_VERSION = ACP::AgentConnection::PROTOCOL_VERSION #: Integer
9
15
 
10
16
  IGNORE = ->(_notification) {} #: ^(ACP::Types::SessionNotification) -> void
11
17
 
12
18
  # @rbs transport: ACP::AgentConnection::_Transport
13
19
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
20
+ # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
21
+ # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
14
22
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
23
+ # @rbs logger: ACP::Transport::_Logger
15
24
  # @rbs return: void
16
- def initialize(transport:, permission:, updates: IGNORE)
25
+ def initialize(transport:, permission:, read_text_file: nil, write_text_file: nil, updates: IGNORE, logger: ACP::Transport::StderrLogger.new)
17
26
  @transport = transport
18
27
  @permission = permission
28
+ @read_text_file = read_text_file
29
+ @write_text_file = write_text_file
19
30
  @updates = updates
31
+ @fs_capabilities = nil
20
32
  @lock = Mutex.new
21
33
  @streams = {}
34
+ @logger = logger
22
35
  end
23
36
 
24
37
  # @rbs return: Thread
25
38
  def start
26
39
  @transport.start(
27
- requests: { 'session/request_permission' => method(:request_permission) },
40
+ requests: {
41
+ 'session/request_permission' => method(:request_permission),
42
+ 'fs/read_text_file' => method(:read_text_file),
43
+ 'fs/write_text_file' => method(:write_text_file)
44
+ },
28
45
  notifications: { 'session/update' => method(:dispatch) }
29
46
  )
30
47
  end
@@ -35,7 +52,23 @@ class ACP::ClientConnection
35
52
  # @rbs request: ACP::Types::InitializeRequest
36
53
  # @rbs return: ACP::Types::InitializeResponse | ACP::RequestError
37
54
  def connect(request)
38
- parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h))
55
+ capabilities = request.client_capabilities&.fs
56
+ unserved = unserved_fs_methods(capabilities)
57
+ unless unserved.empty?
58
+ raise ArgumentError, "initialize advertises fs methods no handler serves: #{unserved.join(', ')}"
59
+ end
60
+
61
+ @fs_capabilities = capabilities
62
+ parse(ACP::Types::InitializeResponse, @transport.request('initialize', request.to_h)).then do |response|
63
+ next response if response.is_a?(ACP::RequestError)
64
+
65
+ next response if response.protocol_version == PROTOCOL_VERSION
66
+
67
+ ACP::RequestError.unsupported_protocol_version(
68
+ requested: request.protocol_version,
69
+ returned: response.protocol_version
70
+ )
71
+ end
39
72
  end
40
73
 
41
74
  # @rbs request: ACP::Types::NewSessionRequest
@@ -64,12 +97,60 @@ class ACP::ClientConnection
64
97
  parse(ACP::Types::ListSessionsResponse, @transport.request('session/list', request.to_h))
65
98
  end
66
99
 
100
+ # The agent does not replay history on resume, so unlike session_load there
101
+ # is nothing to stream and no block is taken.
102
+ #
103
+ # @rbs request: ACP::Types::ResumeSessionRequest
104
+ # @rbs return: ACP::Types::ResumeSessionResponse | ACP::RequestError
105
+ def session_resume(request)
106
+ parse(ACP::Types::ResumeSessionResponse, @transport.request('session/resume', request.to_h))
107
+ end
108
+
109
+ # @rbs request: ACP::Types::CloseSessionRequest
110
+ # @rbs return: ACP::Types::CloseSessionResponse | ACP::RequestError
111
+ def session_close(request)
112
+ parse(ACP::Types::CloseSessionResponse, @transport.request('session/close', request.to_h))
113
+ end
114
+
115
+ # @rbs request: ACP::Types::DeleteSessionRequest
116
+ # @rbs return: ACP::Types::DeleteSessionResponse | ACP::RequestError
117
+ def session_delete(request)
118
+ parse(ACP::Types::DeleteSessionResponse, @transport.request('session/delete', request.to_h))
119
+ end
120
+
67
121
  # @rbs notification: ACP::Types::CancelNotification
68
122
  # @rbs return: void
69
123
  def session_cancel(notification)
70
124
  @transport.notify('session/cancel', notification.to_h)
71
125
  end
72
126
 
127
+ # @rbs request: ACP::Types::SetSessionModeRequest
128
+ # @rbs return: ACP::Types::SetSessionModeResponse | ACP::RequestError
129
+ def session_set_mode(request)
130
+ parse(ACP::Types::SetSessionModeResponse, @transport.request('session/set_mode', request.to_h))
131
+ end
132
+
133
+ # The reply carries the session's complete option list, so it replaces any
134
+ # state held from an earlier reply rather than merging into it.
135
+ #
136
+ # @rbs request: ACP::Types::SetSessionConfigOptionRequest
137
+ # @rbs return: ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError
138
+ def session_set_config_option(request)
139
+ parse(ACP::Types::SetSessionConfigOptionResponse, @transport.request('session/set_config_option', request.to_h))
140
+ end
141
+
142
+ # @rbs request: ACP::Types::AuthenticateRequest
143
+ # @rbs return: ACP::Types::AuthenticateResponse | ACP::RequestError
144
+ def authenticate(request)
145
+ parse(ACP::Types::AuthenticateResponse, @transport.request('authenticate', request.to_h))
146
+ end
147
+
148
+ # @rbs request: ACP::Types::LogoutRequest
149
+ # @rbs return: ACP::Types::LogoutResponse | ACP::RequestError
150
+ def logout(request)
151
+ parse(ACP::Types::LogoutResponse, @transport.request('logout', request.to_h))
152
+ end
153
+
73
154
  private
74
155
 
75
156
  # @rbs type: ACP::AgentConnection::_Parser
@@ -79,6 +160,8 @@ class ACP::ClientConnection
79
160
  return result if result.is_a?(ACP::RequestError)
80
161
 
81
162
  type.from_h(result)
163
+ rescue KeyError, TypeError, NoMethodError
164
+ ACP::RequestError.invalid_response(result)
82
165
  end
83
166
 
84
167
  # The request waits on its own thread so the caller's thread can yield each
@@ -92,7 +175,21 @@ class ACP::ClientConnection
92
175
  # @rbs return: (Hash[String, untyped] | ACP::RequestError)
93
176
  def stream(session_id, method, params)
94
177
  queue = Thread::Queue.new
95
- @lock.synchronize { @streams[session_id] = queue }
178
+ # The check and the insert share one lock acquisition so a concurrent
179
+ # stream cannot register between them.
180
+ claimed = @lock.synchronize do
181
+ if @streams.key?(session_id)
182
+ false
183
+ else
184
+ @streams[session_id] = queue
185
+ true
186
+ end
187
+ end
188
+ unless claimed
189
+ return ACP::RequestError.new(
190
+ code: ACP::RequestError::INVALID_REQUEST, message: "A stream is already open for session #{session_id}"
191
+ )
192
+ end
96
193
  requester = Thread.new do
97
194
  @transport.request(method, params)
98
195
  ensure
@@ -105,12 +202,16 @@ class ACP::ClientConnection
105
202
  end
106
203
 
107
204
  # Pushes under the lock so a stream cannot close its queue between the
108
- # lookup and the push.
205
+ # lookup and the push. A malformed notification has no reply to carry the
206
+ # failure.
109
207
  #
110
208
  # @rbs params: untyped
111
209
  # @rbs return: void
112
210
  def dispatch(params)
113
211
  notification = ACP::Types::SessionNotification.from_h(params)
212
+ rescue KeyError, TypeError, NoMethodError => e
213
+ @logger.warn("dropped malformed session/update: #{e.class}: #{e.message}")
214
+ else
114
215
  queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
115
216
  @updates.call(notification) unless queue
116
217
  end
@@ -124,4 +225,46 @@ class ACP::ClientConnection
124
225
  else
125
226
  @permission.call(request)
126
227
  end
228
+
229
+ # The routes are registered at start, before connect records the advertised
230
+ # capabilities, so each one answers -32601 until then.
231
+ #
232
+ # @rbs params: untyped
233
+ # @rbs return: (ACP::Types::ReadTextFileResponse | ACP::RequestError)
234
+ def read_text_file(params)
235
+ return ACP::RequestError.unadvertised('fs.readTextFile') unless @fs_capabilities&.read_text_file
236
+
237
+ request = ACP::Types::ReadTextFileRequest.from_h(params)
238
+ rescue KeyError, TypeError, NoMethodError
239
+ ACP::RequestError.invalid_params
240
+ else
241
+ # Safe: connect refuses an advertised capability no handler serves.
242
+ handler = @read_text_file #: ^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError)
243
+ handler.call(request)
244
+ end
245
+
246
+ # @rbs params: untyped
247
+ # @rbs return: (ACP::Types::WriteTextFileResponse | ACP::RequestError)
248
+ def write_text_file(params)
249
+ return ACP::RequestError.unadvertised('fs.writeTextFile') unless @fs_capabilities&.write_text_file
250
+
251
+ request = ACP::Types::WriteTextFileRequest.from_h(params)
252
+ rescue KeyError, TypeError, NoMethodError
253
+ ACP::RequestError.invalid_params
254
+ else
255
+ # Safe: connect refuses an advertised capability no handler serves.
256
+ handler = @write_text_file #: ^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError)
257
+ handler.call(request)
258
+ end
259
+
260
+ # The fs capabilities initialize advertises that no injected handler serves.
261
+ #
262
+ # @rbs capabilities: ACP::Types::FileSystemCapabilities?
263
+ # @rbs return: Array[String]
264
+ def unserved_fs_methods(capabilities)
265
+ {
266
+ 'fs.readTextFile' => capabilities&.read_text_file && !@read_text_file,
267
+ 'fs.writeTextFile' => capabilities&.write_text_file && !@write_text_file
268
+ }.select { |_, unserved| unserved }.keys
269
+ end
127
270
  end
@@ -58,11 +58,41 @@ class ACP::RequestError
58
58
  new(code: INVALID_PARAMS, message: 'Invalid params')
59
59
  end
60
60
 
61
+ # The method-not-found code for a method the client's capabilities do not
62
+ # advertise, with a message that names the capability so it can be told
63
+ # apart from an unknown method.
64
+ #
65
+ # @rbs capability: String
66
+ # @rbs return: ACP::RequestError
67
+ def self.unadvertised(capability)
68
+ new(code: METHOD_NOT_FOUND, message: "Client does not advertise #{capability}")
69
+ end
70
+
61
71
  # @rbs return: ACP::RequestError
62
72
  def self.internal_error
63
73
  new(code: INTERNAL_ERROR, message: 'Internal error')
64
74
  end
65
75
 
76
+ # The message tells a malformed peer reply apart from an internal error a
77
+ # handler raised, though both use the -32603 code.
78
+ #
79
+ # @rbs response: untyped
80
+ # @rbs return: ACP::RequestError
81
+ def self.invalid_response(response)
82
+ new(code: INTERNAL_ERROR, message: 'Invalid response', data: response)
83
+ end
84
+
85
+ # A local refusal of an initialize reply that names a version the SDK does
86
+ # not speak; the message names both versions so logs can tell it from a
87
+ # peer's error.
88
+ #
89
+ # @rbs requested: Integer
90
+ # @rbs returned: Integer
91
+ # @rbs return: ACP::RequestError
92
+ def self.unsupported_protocol_version(requested:, returned:)
93
+ new(code: INTERNAL_ERROR, message: "Unsupported protocol version: requested #{requested}, returned #{returned}")
94
+ end
95
+
66
96
  # @rbs return: ACP::RequestError
67
97
  def self.request_cancelled
68
98
  new(code: REQUEST_CANCELLED, message: 'Request cancelled')
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The default sink for dropped messages and swallowed errors: the spec
4
+ # sanctions agents logging to stderr.
5
+ class ACP::Transport::StderrLogger
6
+ # @rbs message: String
7
+ # @rbs return: void
8
+ def warn(message)
9
+ Kernel.warn("acp: WARN: #{message}")
10
+ end
11
+
12
+ # @rbs message: String
13
+ # @rbs return: void
14
+ def error(message)
15
+ Kernel.warn("acp: ERROR: #{message}")
16
+ end
17
+ end