claude_agent 0.7.12 → 0.7.14

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/testing.md +51 -10
  3. data/.claude/settings.json +1 -0
  4. data/ARCHITECTURE.md +237 -0
  5. data/CHANGELOG.md +53 -0
  6. data/CLAUDE.md +2 -0
  7. data/README.md +46 -1
  8. data/Rakefile +17 -0
  9. data/SPEC.md +214 -125
  10. data/lib/claude_agent/client/commands.rb +225 -0
  11. data/lib/claude_agent/client.rb +4 -206
  12. data/lib/claude_agent/content_blocks/generic_block.rb +39 -0
  13. data/lib/claude_agent/content_blocks/image_content_block.rb +54 -0
  14. data/lib/claude_agent/content_blocks/server_tool_result_block.rb +22 -0
  15. data/lib/claude_agent/content_blocks/server_tool_use_block.rb +48 -0
  16. data/lib/claude_agent/content_blocks/text_block.rb +19 -0
  17. data/lib/claude_agent/content_blocks/thinking_block.rb +19 -0
  18. data/lib/claude_agent/content_blocks/tool_result_block.rb +25 -0
  19. data/lib/claude_agent/content_blocks/tool_use_block.rb +134 -0
  20. data/lib/claude_agent/content_blocks.rb +8 -335
  21. data/lib/claude_agent/control_protocol/commands.rb +304 -0
  22. data/lib/claude_agent/control_protocol/lifecycle.rb +116 -0
  23. data/lib/claude_agent/control_protocol/messaging.rb +163 -0
  24. data/lib/claude_agent/control_protocol/primitives.rb +168 -0
  25. data/lib/claude_agent/control_protocol/request_handling.rb +231 -0
  26. data/lib/claude_agent/control_protocol.rb +50 -882
  27. data/lib/claude_agent/conversation.rb +8 -1
  28. data/lib/claude_agent/event_handler.rb +1 -0
  29. data/lib/claude_agent/get_session_info.rb +86 -0
  30. data/lib/claude_agent/hooks.rb +23 -2
  31. data/lib/claude_agent/list_sessions.rb +22 -13
  32. data/lib/claude_agent/message_parser.rb +26 -4
  33. data/lib/claude_agent/messages/conversation.rb +138 -0
  34. data/lib/claude_agent/messages/generic.rb +39 -0
  35. data/lib/claude_agent/messages/hook_lifecycle.rb +158 -0
  36. data/lib/claude_agent/messages/result.rb +80 -0
  37. data/lib/claude_agent/messages/streaming.rb +84 -0
  38. data/lib/claude_agent/messages/system.rb +67 -0
  39. data/lib/claude_agent/messages/task_lifecycle.rb +240 -0
  40. data/lib/claude_agent/messages/tool_lifecycle.rb +95 -0
  41. data/lib/claude_agent/messages.rb +11 -829
  42. data/lib/claude_agent/options/serializer.rb +194 -0
  43. data/lib/claude_agent/options.rb +11 -176
  44. data/lib/claude_agent/query.rb +0 -2
  45. data/lib/claude_agent/sandbox_settings.rb +3 -0
  46. data/lib/claude_agent/session.rb +0 -204
  47. data/lib/claude_agent/session_mutations.rb +148 -0
  48. data/lib/claude_agent/transport/subprocess.rb +2 -2
  49. data/lib/claude_agent/types/mcp.rb +30 -0
  50. data/lib/claude_agent/types/models.rb +146 -0
  51. data/lib/claude_agent/types/operations.rb +38 -0
  52. data/lib/claude_agent/types/sessions.rb +50 -0
  53. data/lib/claude_agent/types/tools.rb +32 -0
  54. data/lib/claude_agent/types.rb +6 -264
  55. data/lib/claude_agent/v2_session.rb +207 -0
  56. data/lib/claude_agent/version.rb +1 -1
  57. data/lib/claude_agent.rb +37 -3
  58. data/sig/claude_agent.rbs +144 -13
  59. metadata +33 -1
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # Public control commands: interrupt, model/permission changes,
6
+ # MCP management, task control, and query methods.
7
+ module Commands
8
+ # Send an interrupt request
9
+ # @return [void]
10
+ def interrupt
11
+ send_control_request(subtype: "interrupt")
12
+ end
13
+
14
+ # Change the permission mode
15
+ # @param mode [String] New permission mode
16
+ # @return [Hash] Response
17
+ def set_permission_mode(mode)
18
+ send_control_request(subtype: "set_permission_mode", mode: mode)
19
+ end
20
+
21
+ # Change the model
22
+ # @param model [String, nil] New model name
23
+ # @return [Hash] Response
24
+ def set_model(model)
25
+ send_control_request(subtype: "set_model", model: model)
26
+ end
27
+
28
+ # Rewind files to a previous state
29
+ # @param user_message_id [String] UUID of user message to rewind to
30
+ # @param dry_run [Boolean] If true, preview changes without modifying files
31
+ # @return [RewindFilesResult] Result with rewind information
32
+ def rewind_files(user_message_id, dry_run: false)
33
+ request = { user_message_id: user_message_id }
34
+ request[:dry_run] = dry_run if dry_run
35
+
36
+ response = send_control_request(subtype: "rewind_files", **request)
37
+
38
+ RewindFilesResult.new(
39
+ can_rewind: response["canRewind"] || response["can_rewind"] || false,
40
+ error: response["error"],
41
+ files_changed: response["filesChanged"] || response["files_changed"],
42
+ insertions: response["insertions"],
43
+ deletions: response["deletions"]
44
+ )
45
+ end
46
+
47
+ # Set maximum thinking tokens (TypeScript SDK parity)
48
+ # @param tokens [Integer, nil] Max thinking tokens (nil to reset)
49
+ # @return [Hash] Response
50
+ def set_max_thinking_tokens(tokens)
51
+ send_control_request(subtype: "set_max_thinking_tokens", max_thinking_tokens: tokens)
52
+ end
53
+
54
+ # Get available slash commands (TypeScript SDK parity)
55
+ # @return [Array<SlashCommand>]
56
+ def supported_commands
57
+ response = send_control_request(subtype: "supported_commands")
58
+ (response["commands"] || []).map do |cmd|
59
+ SlashCommand.new(
60
+ name: cmd["name"],
61
+ description: cmd["description"],
62
+ argument_hint: cmd["argumentHint"]
63
+ )
64
+ end
65
+ end
66
+
67
+ # Get full initialization result (TypeScript SDK parity)
68
+ #
69
+ # Sends the supported_commands request and maps the full response including
70
+ # commands, output style, available output styles, models, and account info.
71
+ #
72
+ # @return [InitializationResult]
73
+ def initialization_result
74
+ response = send_control_request(subtype: "supported_commands")
75
+
76
+ commands = (response["commands"] || []).map do |cmd|
77
+ SlashCommand.new(
78
+ name: cmd["name"],
79
+ description: cmd["description"],
80
+ argument_hint: cmd["argumentHint"]
81
+ )
82
+ end
83
+
84
+ models = (response["models"] || []).map do |model|
85
+ ModelInfo.new(
86
+ value: model["value"],
87
+ display_name: model["displayName"],
88
+ description: model["description"],
89
+ supports_effort: model["supportsEffort"],
90
+ supported_effort_levels: model["supportedEffortLevels"],
91
+ supports_adaptive_thinking: model["supportsAdaptiveThinking"],
92
+ supports_fast_mode: model["supportsFastMode"],
93
+ supports_auto_mode: model["supportsAutoMode"]
94
+ )
95
+ end
96
+
97
+ account_data = response["account"]
98
+ account = if account_data
99
+ AccountInfo.new(
100
+ email: account_data["email"],
101
+ organization: account_data["organization"],
102
+ subscription_type: account_data["subscriptionType"],
103
+ token_source: account_data["tokenSource"],
104
+ api_key_source: account_data["apiKeySource"]
105
+ )
106
+ end
107
+
108
+ agents = (response["agents"] || []).map do |agent|
109
+ AgentInfo.new(
110
+ name: agent["name"],
111
+ description: agent["description"],
112
+ model: agent["model"]
113
+ )
114
+ end
115
+
116
+ InitializationResult.new(
117
+ commands: commands,
118
+ output_style: response["output_style"],
119
+ available_output_styles: response["available_output_styles"] || [],
120
+ models: models,
121
+ account: account,
122
+ agents: agents,
123
+ fast_mode_state: response["fast_mode_state"]
124
+ )
125
+ end
126
+
127
+ # Get available models (TypeScript SDK parity)
128
+ # @return [Array<ModelInfo>]
129
+ def supported_models
130
+ response = send_control_request(subtype: "supported_models")
131
+ (response["models"] || []).map do |model|
132
+ ModelInfo.new(
133
+ value: model["value"],
134
+ display_name: model["displayName"],
135
+ description: model["description"],
136
+ supports_effort: model["supportsEffort"],
137
+ supported_effort_levels: model["supportedEffortLevels"],
138
+ supports_adaptive_thinking: model["supportsAdaptiveThinking"],
139
+ supports_fast_mode: model["supportsFastMode"],
140
+ supports_auto_mode: model["supportsAutoMode"]
141
+ )
142
+ end
143
+ end
144
+
145
+ # Get available agents (TypeScript SDK v0.2.63 parity)
146
+ # @return [Array<AgentInfo>]
147
+ def supported_agents
148
+ response = send_control_request(subtype: "supported_agents")
149
+ (response["agents"] || []).map do |agent|
150
+ AgentInfo.new(
151
+ name: agent["name"],
152
+ description: agent["description"],
153
+ model: agent["model"]
154
+ )
155
+ end
156
+ end
157
+
158
+ # Get MCP server status (TypeScript SDK parity)
159
+ # @return [Array<McpServerStatus>]
160
+ def mcp_server_status
161
+ response = send_control_request(subtype: "mcp_server_status")
162
+ (response["servers"] || []).map do |server|
163
+ McpServerStatus.new(
164
+ name: server["name"],
165
+ status: server["status"],
166
+ server_info: server["serverInfo"],
167
+ error: server["error"],
168
+ config: server["config"],
169
+ scope: server["scope"],
170
+ tools: server["tools"]
171
+ )
172
+ end
173
+ end
174
+
175
+ # Get account information (TypeScript SDK parity)
176
+ # @return [AccountInfo]
177
+ def account_info
178
+ response = send_control_request(subtype: "account_info")
179
+ AccountInfo.new(
180
+ email: response["email"],
181
+ organization: response["organization"],
182
+ subscription_type: response["subscriptionType"],
183
+ token_source: response["tokenSource"],
184
+ api_key_source: response["apiKeySource"]
185
+ )
186
+ end
187
+
188
+ # Reconnect to an MCP server (TypeScript SDK parity)
189
+ #
190
+ # Attempts to reconnect to a disconnected or errored MCP server.
191
+ #
192
+ # @param server_name [String] Name of the MCP server to reconnect
193
+ # @return [Hash] Response from the CLI
194
+ #
195
+ # @example
196
+ # protocol.mcp_reconnect("my-server")
197
+ #
198
+ def mcp_reconnect(server_name)
199
+ send_control_request(subtype: "mcp_reconnect", serverName: server_name)
200
+ end
201
+
202
+ # Enable or disable an MCP server (TypeScript SDK parity)
203
+ #
204
+ # Toggles an MCP server on or off without removing its configuration.
205
+ #
206
+ # @param server_name [String] Name of the MCP server to toggle
207
+ # @param enabled [Boolean] Whether to enable (true) or disable (false) the server
208
+ # @return [Hash] Response from the CLI
209
+ #
210
+ # @example Enable a server
211
+ # protocol.mcp_toggle("my-server", enabled: true)
212
+ #
213
+ # @example Disable a server
214
+ # protocol.mcp_toggle("my-server", enabled: false)
215
+ #
216
+ def mcp_toggle(server_name, enabled:)
217
+ send_control_request(subtype: "mcp_toggle", serverName: server_name, enabled: enabled)
218
+ end
219
+
220
+ # Initiate OAuth authentication for an MCP server (TypeScript SDK v0.2.52 parity)
221
+ #
222
+ # @param server_name [String] Name of the MCP server to authenticate
223
+ # @return [Hash] Response from the CLI
224
+ #
225
+ # @example
226
+ # protocol.mcp_authenticate("my-remote-server")
227
+ #
228
+ def mcp_authenticate(server_name)
229
+ send_control_request(subtype: "mcp_authenticate", serverName: server_name)
230
+ end
231
+
232
+ # Clear stored auth credentials for an MCP server (TypeScript SDK v0.2.52 parity)
233
+ #
234
+ # @param server_name [String] Name of the MCP server to clear auth for
235
+ # @return [Hash] Response from the CLI
236
+ #
237
+ # @example
238
+ # protocol.mcp_clear_auth("my-remote-server")
239
+ #
240
+ def mcp_clear_auth(server_name)
241
+ send_control_request(subtype: "mcp_clear_auth", serverName: server_name)
242
+ end
243
+
244
+ # Stop a running background task (TypeScript SDK parity)
245
+ #
246
+ # Sends a stop signal to a running task. A task_notification message
247
+ # with status 'stopped' will be emitted when the task stops.
248
+ #
249
+ # @param task_id [String] The task ID from task_notification events
250
+ # @return [void]
251
+ #
252
+ # @example
253
+ # protocol.stop_task("task-123")
254
+ #
255
+ def stop_task(task_id)
256
+ send_control_request(subtype: "stop_task", task_id: task_id)
257
+ end
258
+
259
+ # Apply flag settings (TypeScript SDK v0.2.50 parity)
260
+ #
261
+ # Merges the provided settings into the flag settings layer.
262
+ #
263
+ # @param settings [Hash] Settings to merge into the flag layer
264
+ # @return [Hash] Response from the CLI
265
+ #
266
+ # @example
267
+ # protocol.apply_flag_settings({ "model" => "claude-sonnet-4-5-20250514" })
268
+ #
269
+ def apply_flag_settings(settings)
270
+ send_control_request(subtype: "apply_flag_settings", settings: settings)
271
+ end
272
+
273
+ # Dynamically set MCP servers for this session (TypeScript SDK parity)
274
+ #
275
+ # This replaces the current set of dynamically-added MCP servers.
276
+ # Servers that are removed will be disconnected, and new servers will be connected.
277
+ #
278
+ # @param servers [Hash] Map of server name to configuration
279
+ # @return [McpSetServersResult] Result with added, removed, and errors
280
+ #
281
+ # @example
282
+ # result = protocol.set_mcp_servers({
283
+ # "my-server" => { type: "stdio", command: "node", args: ["server.js"] }
284
+ # })
285
+ # puts "Added: #{result.added}"
286
+ # puts "Removed: #{result.removed}"
287
+ #
288
+ def set_mcp_servers(servers)
289
+ # Convert servers hash to format expected by CLI
290
+ servers_config = servers.reject do |_, config|
291
+ config.is_a?(Hash) && (config[:type] == "sdk" || config["type"] == "sdk")
292
+ end
293
+
294
+ response = send_control_request(subtype: "mcp_set_servers", servers: servers_config)
295
+
296
+ McpSetServersResult.new(
297
+ added: response["added"] || [],
298
+ removed: response["removed"] || [],
299
+ errors: response["errors"] || {}
300
+ )
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # Connection lifecycle: start, stop, abort, and the background reader loop.
6
+ module Lifecycle
7
+ # Start the control protocol (initialize connection)
8
+ # @param streaming [Boolean] Whether to use streaming mode
9
+ # @param prompt [String, nil] Initial prompt for non-streaming mode
10
+ # @return [Hash, nil] Server info from initialization
11
+ def start(streaming: true, prompt: nil)
12
+ logger.info("protocol") { "Starting control protocol (streaming=#{streaming})" }
13
+ @transport.connect(streaming: streaming, prompt: prompt)
14
+ @running = true
15
+
16
+ # Start background reader thread
17
+ @reader_thread = Thread.new { reader_loop }
18
+ logger.debug("protocol") { "Reader thread started" }
19
+
20
+ # Always send initialize in streaming mode (Python/TypeScript SDK parity)
21
+ if streaming
22
+ @server_info = send_initialize
23
+ logger.info("protocol") { "Initialize complete" }
24
+ end
25
+
26
+ @server_info
27
+ end
28
+
29
+ # Stop the control protocol
30
+ # @return [void]
31
+ def stop
32
+ logger.info("protocol") { "Stopping control protocol" }
33
+ @running = false
34
+ @transport.end_input
35
+ @reader_thread&.join(5)
36
+ @transport.close
37
+ end
38
+
39
+ # Abort all pending operations (TypeScript SDK parity)
40
+ #
41
+ # This method:
42
+ # 1. Stops the reader loop
43
+ # 2. Fails all pending requests with AbortError
44
+ # 3. Terminates the transport
45
+ #
46
+ # @return [void]
47
+ def abort!
48
+ @running = false
49
+
50
+ # Drain permission queue so reader thread unblocks
51
+ @permission_queue&.drain!(reason: "Operation aborted")
52
+
53
+ # Send cancel requests for pending operations (protocol courtesy)
54
+ @mutex.synchronize do
55
+ @pending_requests.each_key do |request_id|
56
+ begin
57
+ write_message({ type: "control_cancel_request", request_id: request_id })
58
+ rescue
59
+ # Ignore transport errors during abort — fire-and-forget
60
+ end
61
+ end
62
+ end
63
+
64
+ # Fail all pending requests
65
+ @mutex.synchronize do
66
+ @pending_requests.each_key do |request_id|
67
+ @pending_results[request_id] = {
68
+ "subtype" => "error",
69
+ "error" => "Operation aborted"
70
+ }
71
+ end
72
+ @condition.broadcast
73
+ end
74
+
75
+ # Unblock the consumer and terminate the transport
76
+ @message_queue.push(:done)
77
+ @transport.terminate if @transport.respond_to?(:terminate)
78
+ end
79
+
80
+ private
81
+
82
+ # Background thread that reads messages and routes them
83
+ def reader_loop
84
+ @transport.read_messages do |raw|
85
+ # Check abort signal on each iteration
86
+ if @abort_signal&.aborted?
87
+ @running = false
88
+ break
89
+ end
90
+
91
+ break unless @running
92
+
93
+ if raw["type"] == "control_request"
94
+ logger.debug("protocol") { "Control request received: #{raw.dig("request", "subtype")}" }
95
+ handle_control_request(raw)
96
+ elsif raw["type"] == "control_response"
97
+ logger.debug("protocol") { "Control response received: #{raw.dig("response", "request_id")}" }
98
+ handle_control_response(raw)
99
+ else
100
+ # SDK message - queue for consumer
101
+ logger.debug("protocol") { "Queued message: #{raw["type"]}" }
102
+ @message_queue.push(raw)
103
+ end
104
+ end
105
+ rescue IOError, Errno::EPIPE
106
+ logger.debug("protocol") { "Reader thread exiting: transport closed" }
107
+ @running = false
108
+ rescue AbortError
109
+ logger.debug("protocol") { "Reader thread exiting: abort signal" }
110
+ @running = false
111
+ ensure
112
+ @message_queue.push(:done)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # User message sending, iteration, and streaming conversation support.
6
+ module Messaging
7
+ # Send a user message
8
+ # @param content [String, Array] Message content
9
+ # @param session_id [String] Session ID
10
+ # @param uuid [String, nil] Message UUID for file checkpointing
11
+ # @return [void]
12
+ def send_user_message(content, session_id: "default", uuid: nil)
13
+ message = {
14
+ type: "user",
15
+ message: { role: "user", content: content },
16
+ session_id: session_id
17
+ }
18
+ message[:uuid] = uuid if uuid
19
+ write_message(message)
20
+ end
21
+
22
+ # Iterate over incoming messages (SDK messages only, not control)
23
+ # @yield [Message] Parsed message objects
24
+ # @return [Enumerator] If no block given
25
+ # @raise [AbortError] If abort signal is triggered
26
+ def each_message
27
+ return enum_for(:each_message) unless block_given?
28
+
29
+ loop do
30
+ @abort_signal&.check!
31
+
32
+ raw = @message_queue.pop # blocks until data available
33
+ break if raw == :done # sentinel from reader_loop
34
+
35
+ begin
36
+ message = @parser.parse(raw)
37
+ yield message
38
+ rescue AbortError
39
+ raise
40
+ rescue => e
41
+ logger.warn("protocol") { "Message parse error: #{e.message}" }
42
+ end
43
+ end
44
+ end
45
+
46
+ # Receive messages until a ResultMessage is received
47
+ # @yield [Message] Parsed message objects
48
+ # @return [Enumerator] If no block given
49
+ def receive_response
50
+ return enum_for(:receive_response) unless block_given?
51
+
52
+ each_message do |message|
53
+ yield message
54
+ break if message.is_a?(ResultMessage)
55
+ end
56
+ end
57
+
58
+ # Stream user input from an enumerable (TypeScript SDK parity)
59
+ #
60
+ # Sends each message from the input stream to Claude. Messages can be:
61
+ # - String: Sent as user message content
62
+ # - Hash: Must have :content key, optionally :session_id and :uuid
63
+ # - UserMessage: Sent directly
64
+ #
65
+ # @param stream [Enumerable] Input stream of messages
66
+ # @param session_id [String] Default session ID for messages
67
+ # @return [void]
68
+ # @raise [AbortError] If abort signal is triggered
69
+ #
70
+ # @example With strings
71
+ # protocol.stream_input(["Hello", "How are you?"])
72
+ #
73
+ # @example With hashes
74
+ # protocol.stream_input([
75
+ # { content: "Hello", uuid: "msg-1" },
76
+ # { content: "Follow up", session_id: "custom" }
77
+ # ])
78
+ #
79
+ def stream_input(stream, session_id: "default")
80
+ stream.each do |message|
81
+ # Check abort signal before each message
82
+ @abort_signal&.check!
83
+
84
+ case message
85
+ when String
86
+ send_user_message(message, session_id: session_id)
87
+ when Hash
88
+ content = message[:content] || message["content"]
89
+ msg_session = message[:session_id] || message["session_id"] || session_id
90
+ uuid = message[:uuid] || message["uuid"]
91
+ send_user_message(content, session_id: msg_session, uuid: uuid)
92
+ when UserMessage, UserMessageReplay
93
+ send_user_message(message.content, session_id: message.session_id || session_id, uuid: message.uuid)
94
+ else
95
+ raise ArgumentError, "Unknown message type in stream: #{message.class}"
96
+ end
97
+ end
98
+ end
99
+
100
+ # Stream user input and receive responses (TypeScript SDK parity)
101
+ #
102
+ # Sends messages from the input stream in a background thread while
103
+ # yielding responses in the foreground. This enables concurrent input/output.
104
+ #
105
+ # @param stream [Enumerable] Input stream of messages
106
+ # @param session_id [String] Default session ID for messages
107
+ # @yield [Message] Received messages
108
+ # @return [Enumerator] If no block given
109
+ # @raise [AbortError] If abort signal is triggered
110
+ #
111
+ # @example
112
+ # messages = ["Hello", "Tell me more"]
113
+ # protocol.stream_conversation(messages) do |msg|
114
+ # case msg
115
+ # when ClaudeAgent::AssistantMessage
116
+ # puts msg.text
117
+ # when ClaudeAgent::ResultMessage
118
+ # puts "Done!"
119
+ # end
120
+ # end
121
+ #
122
+ def stream_conversation(stream, session_id: "default", &block)
123
+ return enum_for(:stream_conversation, stream, session_id: session_id) unless block_given?
124
+
125
+ # Track errors from the sender thread
126
+ sender_error = nil
127
+
128
+ # Start sender thread
129
+ sender_thread = Thread.new do
130
+ stream_input(stream, session_id: session_id)
131
+ rescue AbortError => e
132
+ sender_error = e
133
+ rescue => e
134
+ sender_error = e
135
+ # Don't re-raise here; let the main thread handle it
136
+ end
137
+
138
+ # Yield responses until we get a ResultMessage or error
139
+ begin
140
+ each_message do |message|
141
+ # Check if sender had an error
142
+ if sender_error
143
+ raise sender_error if sender_error.is_a?(AbortError)
144
+
145
+ raise Error, "Stream input error: #{sender_error.message}"
146
+ end
147
+
148
+ yield message
149
+ break if message.is_a?(ResultMessage)
150
+ end
151
+ ensure
152
+ # Wait for sender to finish
153
+ sender_thread.join(1)
154
+ end
155
+
156
+ # Check for sender errors after loop
157
+ raise sender_error if sender_error.is_a?(AbortError)
158
+
159
+ raise Error, "Stream input error: #{sender_error.message}" if sender_error
160
+ end
161
+ end
162
+ end
163
+ end