acp-sdk 0.4.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: f0121ff621c45deb5578a15051611ea98d8d174193528c3df214fe4e036561ff
4
- data.tar.gz: e26701095772e5bbf41a18a8e2bc9f4ac9609657aae1dee7c82891de6cf5b48c
3
+ metadata.gz: 77ac970489b956c7216d4c94a8d5cc6e7bf3fe5bfd10110cb8558c119f58f69d
4
+ data.tar.gz: 29ec919463eb48d06811aa8b6c736163fd7872348ce99f3f8cdc10c5670bf8c4
5
5
  SHA512:
6
- metadata.gz: 66f390b3cf58c661064fe12ca5c7bbf04f560a81187f8cd37cce6d820870eb9f83717b655d04305a9a926e3cc08af618c1956acb7c2425899d2d3238e05d2651
7
- data.tar.gz: 13853bc47c5c15a249296d9418fb951f52d512fea28f92fe9bf601c1a3e43d98840cbbbbfc181ff53150572db0393388553bf27f3c1746ca135f0467905d4270
6
+ metadata.gz: acf89beeb444ff33ac273aececd55d645792fe591f346cbdce0a099b6b6bff4eedb4025cceadd3a46ec62253c85b111c0149466960707b3feaa92623dab600a8
7
+ data.tar.gz: 92fba44ccd9334876fa8cb689728a7a19cc3d473bf1e7ee27fe215d72e48952e04bc9c0428609e31e883bdef6986e1ec7b4c9383d8a37271646593b33ff069bd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
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
+
3
23
  ## [0.4.0](https://github.com/bottrall/acp-sdk/compare/v0.3.0...v0.4.0) (2026-10-02)
4
24
 
5
25
 
data/README.md CHANGED
@@ -55,12 +55,13 @@ The agent's contract:
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
@@ -102,19 +125,8 @@ class ACP::AgentConnection::Client
102
125
  # @rbs request: ACP::AgentConnection::_Response
103
126
  # @rbs return: untyped
104
127
  def terminal_call(rpc_method, type, request)
105
- return unadvertised('terminal') unless capabilities&.terminal
128
+ return ACP::RequestError.unadvertised('terminal') unless capabilities&.terminal
106
129
 
107
130
  call(rpc_method, type, request)
108
131
  end
109
-
110
- # Same method-not-found code a peer would send, but with a message that says
111
- # the refusal was local, so logs can tell the two cases apart.
112
- #
113
- # @rbs capability: String
114
- # @rbs return: ACP::RequestError
115
- def unadvertised(capability)
116
- ACP::RequestError.new(
117
- code: ACP::RequestError::METHOD_NOT_FOUND, message: "Client does not advertise #{capability}"
118
- )
119
- end
120
132
  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 = [
@@ -27,16 +30,22 @@ class ACP::AgentConnection
27
30
  # @rbs capabilities: ACP::Types::AgentCapabilities
28
31
  # @rbs agent_info: ACP::Types::Implementation?
29
32
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
33
+ # @rbs logger: ACP::Transport::_Logger
30
34
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
31
35
  # @rbs return: void
32
- 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
+ )
33
44
  @transport = transport
34
- @initialize_response = ACP::Types::InitializeResponse.new(
35
- protocol_version: PROTOCOL_VERSION,
36
- agent_capabilities: capabilities,
37
- auth_methods:,
38
- agent_info:
39
- )
45
+ @logger = logger
46
+ @capabilities = capabilities
47
+ @agent_info = agent_info
48
+ @auth_methods = auth_methods
40
49
  @factory = factory
41
50
  end
42
51
 
@@ -44,7 +53,10 @@ class ACP::AgentConnection
44
53
  def start
45
54
  client = ACP::AgentConnection::Client.new(peer: @transport)
46
55
  agent = @factory.call(client)
47
- 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)) }
48
60
  missing = advertised.map(&:agent_method).reject { |name| agent.respond_to?(name) }
49
61
  raise ArgumentError, "initialize advertises methods the agent lacks: #{missing.join(', ')}" unless missing.empty?
50
62
 
@@ -82,7 +94,20 @@ class ACP::AgentConnection
82
94
  # @rbs agent: ACP::AgentConnection::_Agent
83
95
  # @rbs return: Hash[String, ^(untyped) -> void]
84
96
  def notifications(agent)
85
- { '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)
86
111
  end
87
112
 
88
113
  # Generated from_h raises on a missing key or a value of the wrong shape.
@@ -104,8 +129,34 @@ class ACP::AgentConnection
104
129
  # @rbs request: ACP::Types::InitializeRequest
105
130
  # @rbs return: ACP::Types::InitializeResponse
106
131
  def connect(client, request)
107
- client.capabilities = request.client_capabilities || ACP::Types::ClientCapabilities.new
108
- @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')
109
160
  end
110
161
 
111
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
@@ -94,7 +175,21 @@ class ACP::ClientConnection
94
175
  # @rbs return: (Hash[String, untyped] | ACP::RequestError)
95
176
  def stream(session_id, method, params)
96
177
  queue = Thread::Queue.new
97
- @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
98
193
  requester = Thread.new do
99
194
  @transport.request(method, params)
100
195
  ensure
@@ -107,12 +202,16 @@ class ACP::ClientConnection
107
202
  end
108
203
 
109
204
  # Pushes under the lock so a stream cannot close its queue between the
110
- # lookup and the push.
205
+ # lookup and the push. A malformed notification has no reply to carry the
206
+ # failure.
111
207
  #
112
208
  # @rbs params: untyped
113
209
  # @rbs return: void
114
210
  def dispatch(params)
115
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
116
215
  queue = @lock.synchronize { @streams[notification.session_id]&.push(notification.update) }
117
216
  @updates.call(notification) unless queue
118
217
  end
@@ -126,4 +225,46 @@ class ACP::ClientConnection
126
225
  else
127
226
  @permission.call(request)
128
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
129
270
  end
@@ -58,6 +58,16 @@ 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')
@@ -72,6 +82,17 @@ class ACP::RequestError
72
82
  new(code: INTERNAL_ERROR, message: 'Invalid response', data: response)
73
83
  end
74
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
+
75
96
  # @rbs return: ACP::RequestError
76
97
  def self.request_cancelled
77
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
@@ -15,6 +15,7 @@ class ACP::Transport::Stdio
15
15
  # @rbs @pending: Hash[untyped, Thread::Queue]
16
16
  # @rbs @next_id: Integer
17
17
  # @rbs @closed: bool
18
+ # @rbs @logger: ACP::Transport::_Logger
18
19
 
19
20
  CONNECTION_CLOSED = ACP::RequestError.new(
20
21
  code: ACP::RequestError::INTERNAL_ERROR, message: 'Connection closed'
@@ -22,8 +23,9 @@ class ACP::Transport::Stdio
22
23
 
23
24
  # @rbs input: _Reader
24
25
  # @rbs output: _Writer
26
+ # @rbs logger: ACP::Transport::_Logger
25
27
  # @rbs return: void
26
- def initialize(input:, output:)
28
+ def initialize(input:, output:, logger: ACP::Transport::StderrLogger.new)
27
29
  @input = input
28
30
  @output = output
29
31
  @write_lock = Mutex.new
@@ -31,6 +33,7 @@ class ACP::Transport::Stdio
31
33
  @pending = {}
32
34
  @next_id = 0
33
35
  @closed = false
36
+ @logger = logger
34
37
  end
35
38
 
36
39
  # Returns the reader thread, which ends at EOF on input after releasing any
@@ -94,7 +97,7 @@ class ACP::Transport::Stdio
94
97
  # Run on the reader thread so notifications (session/update) keep their
95
98
  # order; a JSON-RPC notification has no reply to carry a handler error.
96
99
  handler = notifications[method]
97
- quietly { handler.call(message['params']) } if handler
100
+ quietly('notification handler') { handler.call(message['params']) } if handler
98
101
  elsif message.key?('result')
99
102
  settle(message['id'], message['result'])
100
103
  elsif error.is_a?(Hash) && error['code'].is_a?(Integer) && error['message'].is_a?(String)
@@ -126,7 +129,7 @@ class ACP::Transport::Stdio
126
129
  case outcome
127
130
  when ACP::Transport::Reply
128
131
  reply(id, outcome.result)
129
- quietly(&outcome.after)
132
+ quietly('reply after callback', &outcome.after)
130
133
  else reply(id, outcome)
131
134
  end
132
135
  end
@@ -136,12 +139,13 @@ class ACP::Transport::Stdio
136
139
  # Reply's after once the reply is on the wire. An exception must not kill
137
140
  # the reader thread or print a thread report.
138
141
  #
142
+ # @rbs label: String
139
143
  # @rbs &block: () -> void
140
144
  # @rbs return: void
141
- def quietly
145
+ def quietly(label)
142
146
  yield
143
- rescue StandardError
144
- nil
147
+ rescue StandardError => e
148
+ @logger.error("#{label} raised #{e.class}: #{e.message}")
145
149
  end
146
150
 
147
151
  # Handlers are application code at the protocol boundary: an exception
@@ -198,6 +202,9 @@ class ACP::Transport::Stdio
198
202
  end
199
203
  end
200
204
 
205
+ # A closed peer must not kill the thread writing the reply: the reader has
206
+ # to keep draining input so pending requests still settle at EOF.
207
+ #
201
208
  # @rbs message: Hash[String, untyped]
202
209
  # @rbs return: void
203
210
  def write(message)
@@ -206,5 +213,7 @@ class ACP::Transport::Stdio
206
213
  @output.write(line)
207
214
  @output.flush
208
215
  end
216
+ rescue Errno::EPIPE => e
217
+ @logger.warn("write to closed peer: #{e.class}: #{e.message}")
209
218
  end
210
219
  end
data/lib/acp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ACP
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -55,6 +55,14 @@ class ACP::AgentConnection::Client
55
55
  # @rbs return: ACP::Types::ReleaseTerminalResponse | ACP::RequestError
56
56
  def release_terminal: (ACP::Types::ReleaseTerminalRequest request) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
57
57
 
58
+ # @rbs request: ACP::Types::CreateElicitationRequest
59
+ # @rbs return: ACP::Types::CreateElicitationResponse | ACP::RequestError
60
+ def create_elicitation: (ACP::Types::CreateElicitationRequest request) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
61
+
62
+ # @rbs notification: ACP::Types::CompleteElicitationNotification
63
+ # @rbs return: void
64
+ def complete_elicitation: (ACP::Types::CompleteElicitationNotification notification) -> void
65
+
58
66
  private
59
67
 
60
68
  # @rbs rpc_method: String
@@ -68,11 +76,4 @@ class ACP::AgentConnection::Client
68
76
  # @rbs request: ACP::AgentConnection::_Response
69
77
  # @rbs return: untyped
70
78
  def terminal_call: (String rpc_method, ACP::AgentConnection::_Parser type, ACP::AgentConnection::_Response request) -> untyped
71
-
72
- # Same method-not-found code a peer would send, but with a message that says
73
- # the refusal was local, so logs can tell the two cases apart.
74
- #
75
- # @rbs capability: String
76
- # @rbs return: ACP::RequestError
77
- def unadvertised: (String capability) -> ACP::RequestError
78
79
  end
@@ -1,12 +1,18 @@
1
1
  # Generated from lib/acp/agent_connection.rb with RBS::Inline
2
2
 
3
3
  class ACP::AgentConnection
4
- @transport: ACP::AgentConnection::_Transport
5
-
6
- @initialize_response: ACP::Types::InitializeResponse
4
+ @logger: ACP::Transport::_Logger
7
5
 
8
6
  @factory: ^(ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
9
7
 
8
+ @auth_methods: Array[ACP::Types::AuthMethod::t]
9
+
10
+ @agent_info: ACP::Types::Implementation?
11
+
12
+ @capabilities: ACP::Types::AgentCapabilities
13
+
14
+ @transport: ACP::AgentConnection::_Transport
15
+
10
16
  PROTOCOL_VERSION: Integer
11
17
 
12
18
  OPTIONAL: Array[ACP::AgentConnection::_OptionalMethod]
@@ -20,9 +26,10 @@ class ACP::AgentConnection
20
26
  # @rbs capabilities: ACP::Types::AgentCapabilities
21
27
  # @rbs agent_info: ACP::Types::Implementation?
22
28
  # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
29
+ # @rbs logger: ACP::Transport::_Logger
23
30
  # @rbs &factory: (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent
24
31
  # @rbs return: void
25
- def initialize: (transport: ACP::AgentConnection::_Transport, capabilities: ACP::Types::AgentCapabilities, ?agent_info: ACP::Types::Implementation?, ?auth_methods: Array[ACP::Types::AuthMethod::t]) { (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent } -> void
32
+ def initialize: (transport: ACP::AgentConnection::_Transport, capabilities: ACP::Types::AgentCapabilities, ?agent_info: ACP::Types::Implementation?, ?auth_methods: Array[ACP::Types::AuthMethod::t], ?logger: ACP::Transport::_Logger) { (ACP::AgentConnection::Client) -> ACP::AgentConnection::_Agent } -> void
26
33
 
27
34
  # @rbs return: Thread
28
35
  def start: () -> Thread
@@ -38,6 +45,13 @@ class ACP::AgentConnection
38
45
  # @rbs return: Hash[String, ^(untyped) -> void]
39
46
  def notifications: (ACP::AgentConnection::_Agent agent) -> Hash[String, ^(untyped) -> void]
40
47
 
48
+ # A notification has no reply to carry a parse failure.
49
+ #
50
+ # @rbs agent: ACP::AgentConnection::_Agent
51
+ # @rbs params: untyped
52
+ # @rbs return: void
53
+ def cancel: (ACP::AgentConnection::_Agent agent, untyped params) -> void
54
+
41
55
  # Generated from_h raises on a missing key or a value of the wrong shape.
42
56
  #
43
57
  # @rbs type: ACP::AgentConnection::_Parser
@@ -50,6 +64,18 @@ class ACP::AgentConnection
50
64
  # @rbs return: ACP::Types::InitializeResponse
51
65
  def connect: (ACP::AgentConnection::Client client, ACP::Types::InitializeRequest request) -> ACP::Types::InitializeResponse
52
66
 
67
+ # @rbs auth_methods: Array[ACP::Types::AuthMethod::t]
68
+ # @rbs return: ACP::Types::InitializeResponse
69
+ def initialize_response: (Array[ACP::Types::AuthMethod::t] auth_methods) -> ACP::Types::InitializeResponse
70
+
71
+ # @rbs capabilities: ACP::Types::ClientCapabilities
72
+ # @rbs return: Array[ACP::Types::AuthMethod::t]
73
+ def auth_methods: (ACP::Types::ClientCapabilities capabilities) -> Array[ACP::Types::AuthMethod::t]
74
+
75
+ # @rbs method: ACP::Types::AuthMethod::t
76
+ # @rbs return: bool
77
+ def terminal_auth_method?: (ACP::Types::AuthMethod::t method) -> bool
78
+
53
79
  # session_created runs after the reply because the client must know the
54
80
  # session id before updates for it (e.g. available commands) arrive.
55
81
  #
@@ -1,23 +1,36 @@
1
1
  # Generated from lib/acp/client_connection.rb with RBS::Inline
2
2
 
3
3
  class ACP::ClientConnection
4
+ @logger: ACP::Transport::_Logger
5
+
4
6
  @streams: Hash[String, Thread::Queue]
5
7
 
6
8
  @lock: Thread::Mutex
7
9
 
10
+ @fs_capabilities: ACP::Types::FileSystemCapabilities?
11
+
8
12
  @updates: ACP::ClientConnection::_UpdateHandler
9
13
 
14
+ @write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
15
+
16
+ @read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
17
+
10
18
  @permission: ACP::ClientConnection::_PermissionHandler
11
19
 
12
20
  @transport: ACP::AgentConnection::_Transport
13
21
 
22
+ PROTOCOL_VERSION: Integer
23
+
14
24
  IGNORE: ^(ACP::Types::SessionNotification) -> void
15
25
 
16
26
  # @rbs transport: ACP::AgentConnection::_Transport
17
27
  # @rbs permission: ACP::ClientConnection::_PermissionHandler
28
+ # @rbs read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?
29
+ # @rbs write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?
18
30
  # @rbs updates: ACP::ClientConnection::_UpdateHandler
31
+ # @rbs logger: ACP::Transport::_Logger
19
32
  # @rbs return: void
20
- def initialize: (transport: ACP::AgentConnection::_Transport, permission: ACP::ClientConnection::_PermissionHandler, ?updates: ACP::ClientConnection::_UpdateHandler) -> void
33
+ def initialize: (transport: ACP::AgentConnection::_Transport, permission: ACP::ClientConnection::_PermissionHandler, ?read_text_file: (^(ACP::Types::ReadTextFileRequest) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError))?, ?write_text_file: (^(ACP::Types::WriteTextFileRequest) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError))?, ?updates: ACP::ClientConnection::_UpdateHandler, ?logger: ACP::Transport::_Logger) -> void
21
34
 
22
35
  # @rbs return: Thread
23
36
  def start: () -> Thread
@@ -47,10 +60,44 @@ class ACP::ClientConnection
47
60
  # @rbs return: ACP::Types::ListSessionsResponse | ACP::RequestError
48
61
  def session_list: (ACP::Types::ListSessionsRequest request) -> (ACP::Types::ListSessionsResponse | ACP::RequestError)
49
62
 
63
+ # The agent does not replay history on resume, so unlike session_load there
64
+ # is nothing to stream and no block is taken.
65
+ #
66
+ # @rbs request: ACP::Types::ResumeSessionRequest
67
+ # @rbs return: ACP::Types::ResumeSessionResponse | ACP::RequestError
68
+ def session_resume: (ACP::Types::ResumeSessionRequest request) -> (ACP::Types::ResumeSessionResponse | ACP::RequestError)
69
+
70
+ # @rbs request: ACP::Types::CloseSessionRequest
71
+ # @rbs return: ACP::Types::CloseSessionResponse | ACP::RequestError
72
+ def session_close: (ACP::Types::CloseSessionRequest request) -> (ACP::Types::CloseSessionResponse | ACP::RequestError)
73
+
74
+ # @rbs request: ACP::Types::DeleteSessionRequest
75
+ # @rbs return: ACP::Types::DeleteSessionResponse | ACP::RequestError
76
+ def session_delete: (ACP::Types::DeleteSessionRequest request) -> (ACP::Types::DeleteSessionResponse | ACP::RequestError)
77
+
50
78
  # @rbs notification: ACP::Types::CancelNotification
51
79
  # @rbs return: void
52
80
  def session_cancel: (ACP::Types::CancelNotification notification) -> void
53
81
 
82
+ # @rbs request: ACP::Types::SetSessionModeRequest
83
+ # @rbs return: ACP::Types::SetSessionModeResponse | ACP::RequestError
84
+ def session_set_mode: (ACP::Types::SetSessionModeRequest request) -> (ACP::Types::SetSessionModeResponse | ACP::RequestError)
85
+
86
+ # The reply carries the session's complete option list, so it replaces any
87
+ # state held from an earlier reply rather than merging into it.
88
+ #
89
+ # @rbs request: ACP::Types::SetSessionConfigOptionRequest
90
+ # @rbs return: ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError
91
+ def session_set_config_option: (ACP::Types::SetSessionConfigOptionRequest request) -> (ACP::Types::SetSessionConfigOptionResponse | ACP::RequestError)
92
+
93
+ # @rbs request: ACP::Types::AuthenticateRequest
94
+ # @rbs return: ACP::Types::AuthenticateResponse | ACP::RequestError
95
+ def authenticate: (ACP::Types::AuthenticateRequest request) -> (ACP::Types::AuthenticateResponse | ACP::RequestError)
96
+
97
+ # @rbs request: ACP::Types::LogoutRequest
98
+ # @rbs return: ACP::Types::LogoutResponse | ACP::RequestError
99
+ def logout: (ACP::Types::LogoutRequest request) -> (ACP::Types::LogoutResponse | ACP::RequestError)
100
+
54
101
  private
55
102
 
56
103
  # @rbs type: ACP::AgentConnection::_Parser
@@ -70,7 +117,8 @@ class ACP::ClientConnection
70
117
  def stream: (String session_id, String method, Hash[String, untyped] params) { (ACP::Types::SessionUpdate::t) -> void } -> (Hash[String, untyped] | ACP::RequestError)
71
118
 
72
119
  # Pushes under the lock so a stream cannot close its queue between the
73
- # lookup and the push.
120
+ # lookup and the push. A malformed notification has no reply to carry the
121
+ # failure.
74
122
  #
75
123
  # @rbs params: untyped
76
124
  # @rbs return: void
@@ -79,4 +127,21 @@ class ACP::ClientConnection
79
127
  # @rbs params: untyped
80
128
  # @rbs return: (ACP::Types::RequestPermissionResponse | ACP::RequestError)
81
129
  def request_permission: (untyped params) -> (ACP::Types::RequestPermissionResponse | ACP::RequestError)
130
+
131
+ # The routes are registered at start, before connect records the advertised
132
+ # capabilities, so each one answers -32601 until then.
133
+ #
134
+ # @rbs params: untyped
135
+ # @rbs return: (ACP::Types::ReadTextFileResponse | ACP::RequestError)
136
+ def read_text_file: (untyped params) -> (ACP::Types::ReadTextFileResponse | ACP::RequestError)
137
+
138
+ # @rbs params: untyped
139
+ # @rbs return: (ACP::Types::WriteTextFileResponse | ACP::RequestError)
140
+ def write_text_file: (untyped params) -> (ACP::Types::WriteTextFileResponse | ACP::RequestError)
141
+
142
+ # The fs capabilities initialize advertises that no injected handler serves.
143
+ #
144
+ # @rbs capabilities: ACP::Types::FileSystemCapabilities?
145
+ # @rbs return: Array[String]
146
+ def unserved_fs_methods: (ACP::Types::FileSystemCapabilities? capabilities) -> Array[String]
82
147
  end
@@ -50,6 +50,14 @@ class ACP::RequestError
50
50
  # @rbs return: ACP::RequestError
51
51
  def self.invalid_params: () -> ACP::RequestError
52
52
 
53
+ # The method-not-found code for a method the client's capabilities do not
54
+ # advertise, with a message that names the capability so it can be told
55
+ # apart from an unknown method.
56
+ #
57
+ # @rbs capability: String
58
+ # @rbs return: ACP::RequestError
59
+ def self.unadvertised: (String capability) -> ACP::RequestError
60
+
53
61
  # @rbs return: ACP::RequestError
54
62
  def self.internal_error: () -> ACP::RequestError
55
63
 
@@ -60,6 +68,15 @@ class ACP::RequestError
60
68
  # @rbs return: ACP::RequestError
61
69
  def self.invalid_response: (untyped response) -> ACP::RequestError
62
70
 
71
+ # A local refusal of an initialize reply that names a version the SDK does
72
+ # not speak; the message names both versions so logs can tell it from a
73
+ # peer's error.
74
+ #
75
+ # @rbs requested: Integer
76
+ # @rbs returned: Integer
77
+ # @rbs return: ACP::RequestError
78
+ def self.unsupported_protocol_version: (requested: Integer, returned: Integer) -> ACP::RequestError
79
+
63
80
  # @rbs return: ACP::RequestError
64
81
  def self.request_cancelled: () -> ACP::RequestError
65
82
 
@@ -0,0 +1,13 @@
1
+ # Generated from lib/acp/transport/stderr_logger.rb with RBS::Inline
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: (String message) -> void
9
+
10
+ # @rbs message: String
11
+ # @rbs return: void
12
+ def error: (String message) -> void
13
+ end
@@ -6,6 +6,8 @@
6
6
  # outbound request mid-turn and still see notifications (e.g. session/cancel)
7
7
  # that arrive meanwhile.
8
8
  class ACP::Transport::Stdio
9
+ @logger: ACP::Transport::_Logger
10
+
9
11
  @closed: bool
10
12
 
11
13
  @next_id: Integer
@@ -24,8 +26,9 @@ class ACP::Transport::Stdio
24
26
 
25
27
  # @rbs input: _Reader
26
28
  # @rbs output: _Writer
29
+ # @rbs logger: ACP::Transport::_Logger
27
30
  # @rbs return: void
28
- def initialize: (input: _Reader, output: _Writer) -> void
31
+ def initialize: (input: _Reader, output: _Writer, ?logger: ACP::Transport::_Logger) -> void
29
32
 
30
33
  # Returns the reader thread, which ends at EOF on input after releasing any
31
34
  # pending outbound requests.
@@ -73,9 +76,10 @@ class ACP::Transport::Stdio
73
76
  # Reply's after once the reply is on the wire. An exception must not kill
74
77
  # the reader thread or print a thread report.
75
78
  #
79
+ # @rbs label: String
76
80
  # @rbs &block: () -> void
77
81
  # @rbs return: void
78
- def quietly: () { () -> void } -> void
82
+ def quietly: (String label) { () -> void } -> void
79
83
 
80
84
  # Handlers are application code at the protocol boundary: an exception
81
85
  # becomes an error response instead of killing the thread serving it.
@@ -105,6 +109,9 @@ class ACP::Transport::Stdio
105
109
  # @rbs return: void
106
110
  def close: () -> void
107
111
 
112
+ # A closed peer must not kill the thread writing the reply: the reader has
113
+ # to keep draining input so pending requests still settle at EOF.
114
+ #
108
115
  # @rbs message: Hash[String, untyped]
109
116
  # @rbs return: void
110
117
  def write: (Hash[String, untyped] message) -> void
@@ -79,6 +79,8 @@ class ACP::AgentConnection
79
79
  def wait_for_terminal_exit: (ACP::Types::WaitForTerminalExitRequest) -> (ACP::Types::WaitForTerminalExitResponse | ACP::RequestError)
80
80
  def kill_terminal: (ACP::Types::KillTerminalRequest) -> (ACP::Types::KillTerminalResponse | ACP::RequestError)
81
81
  def release_terminal: (ACP::Types::ReleaseTerminalRequest) -> (ACP::Types::ReleaseTerminalResponse | ACP::RequestError)
82
+ def create_elicitation: (ACP::Types::CreateElicitationRequest request) -> (ACP::Types::CreateElicitationResponse | ACP::RequestError)
83
+ def complete_elicitation: (ACP::Types::CompleteElicitationNotification notification) -> void
82
84
  def capabilities: () -> ACP::Types::ClientCapabilities?
83
85
  end
84
86
 
@@ -0,0 +1,8 @@
1
+ module ACP::Transport
2
+ # Receives the reports the SDK logs for dropped messages and swallowed
3
+ # errors; a log message is a single line with no trailing newline.
4
+ interface _Logger
5
+ def warn: (String message) -> void
6
+ def error: (String message) -> void
7
+ end
8
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -48,6 +48,7 @@ files:
48
48
  - lib/acp/sdk.rb
49
49
  - lib/acp/transport.rb
50
50
  - lib/acp/transport/reply.rb
51
+ - lib/acp/transport/stderr_logger.rb
51
52
  - lib/acp/transport/stdio.rb
52
53
  - lib/acp/types.rb
53
54
  - lib/acp/types/agent_auth_capabilities.rb
@@ -262,6 +263,7 @@ files:
262
263
  - sig/generated/acp/sdk.rbs
263
264
  - sig/generated/acp/transport.rbs
264
265
  - sig/generated/acp/transport/reply.rbs
266
+ - sig/generated/acp/transport/stderr_logger.rbs
265
267
  - sig/generated/acp/transport/stdio.rbs
266
268
  - sig/generated/acp/types.rbs
267
269
  - sig/generated/acp/types/agent_auth_capabilities.rbs
@@ -470,6 +472,7 @@ files:
470
472
  - sig/manual/acp/agent_connection/optional_method/resume_session.rbs
471
473
  - sig/manual/acp/agent_process.rbs
472
474
  - sig/manual/acp/client_connection.rbs
475
+ - sig/manual/acp/transport.rbs
473
476
  - sig/manual/acp/transport/stdio.rbs
474
477
  homepage: https://github.com/bottrall/acp-sdk
475
478
  licenses: