claude_agent 0.1.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.
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Parses raw JSON messages from the CLI into typed message objects
5
+ #
6
+ # @example
7
+ # parser = MessageParser.new
8
+ # message = parser.parse({"type" => "assistant", "message" => {...}})
9
+ #
10
+ class MessageParser
11
+ # Parse a raw message hash into a typed message object
12
+ #
13
+ # @param raw [Hash] Raw message from CLI
14
+ # @return [UserMessage, UserMessageReplay, AssistantMessage, SystemMessage, ResultMessage, StreamEvent, CompactBoundaryMessage, StatusMessage, ToolProgressMessage, HookResponseMessage, AuthStatusMessage]
15
+ # @raise [MessageParseError] If message cannot be parsed
16
+ def parse(raw)
17
+ type = raw["type"]
18
+
19
+ case type
20
+ when "user"
21
+ parse_user_message(raw)
22
+ when "assistant"
23
+ parse_assistant_message(raw)
24
+ when "system"
25
+ # Check for special system subtypes
26
+ case raw["subtype"]
27
+ when "compact_boundary"
28
+ parse_compact_boundary_message(raw)
29
+ when "status"
30
+ parse_status_message(raw)
31
+ when "hook_response"
32
+ parse_hook_response_message(raw)
33
+ else
34
+ parse_system_message(raw)
35
+ end
36
+ when "result"
37
+ parse_result_message(raw)
38
+ when "stream_event"
39
+ parse_stream_event(raw)
40
+ when "tool_progress"
41
+ parse_tool_progress_message(raw)
42
+ when "auth_status"
43
+ parse_auth_status_message(raw)
44
+ else
45
+ raise MessageParseError.new("Unknown message type: #{type}", raw_message: raw)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Fetch a value from a hash, trying both snake_case and camelCase keys
52
+ # @param raw [Hash] The hash to fetch from
53
+ # @param snake_key [Symbol, String] The snake_case key to try
54
+ # @param default [Object] Default value if neither key exists
55
+ # @return [Object] The value or default
56
+ def fetch_dual(raw, snake_key, default = nil)
57
+ snake_str = snake_key.to_s
58
+ camel_str = snake_str.camelize(:lower)
59
+ raw[snake_str] || raw[camel_str] || default
60
+ end
61
+
62
+ def parse_user_message(raw)
63
+ message = raw["message"] || {}
64
+ content = parse_user_content(message["content"])
65
+
66
+ is_replay = fetch_dual(raw, :is_replay)
67
+ is_synthetic = fetch_dual(raw, :is_synthetic)
68
+ tool_use_result = fetch_dual(raw, :tool_use_result)
69
+
70
+ if is_replay
71
+ UserMessageReplay.new(
72
+ content: content,
73
+ uuid: raw["uuid"],
74
+ session_id: fetch_dual(raw, :session_id),
75
+ parent_tool_use_id: raw["parent_tool_use_id"],
76
+ is_replay: true,
77
+ is_synthetic: is_synthetic,
78
+ tool_use_result: tool_use_result
79
+ )
80
+ else
81
+ UserMessage.new(
82
+ content: content,
83
+ uuid: raw["uuid"],
84
+ session_id: fetch_dual(raw, :session_id),
85
+ parent_tool_use_id: raw["parent_tool_use_id"]
86
+ )
87
+ end
88
+ end
89
+
90
+ def parse_assistant_message(raw)
91
+ message = raw["message"] || {}
92
+ content_raw = message["content"] || []
93
+ content = content_raw.map { |block| parse_content_block(block) }
94
+
95
+ AssistantMessage.new(
96
+ content: content,
97
+ model: message["model"] || raw["model"] || "unknown",
98
+ uuid: raw["uuid"],
99
+ session_id: fetch_dual(raw, :session_id),
100
+ error: message["error"] || raw["error"],
101
+ parent_tool_use_id: raw["parent_tool_use_id"]
102
+ )
103
+ end
104
+
105
+ def parse_system_message(raw)
106
+ SystemMessage.new(
107
+ subtype: raw["subtype"] || "unknown",
108
+ data: raw["data"] || raw
109
+ )
110
+ end
111
+
112
+ def parse_compact_boundary_message(raw)
113
+ CompactBoundaryMessage.new(
114
+ uuid: raw["uuid"] || "",
115
+ session_id: fetch_dual(raw, :session_id, ""),
116
+ compact_metadata: fetch_dual(raw, :compact_metadata, {})
117
+ )
118
+ end
119
+
120
+ def parse_result_message(raw)
121
+ permission_denials = parse_permission_denials(fetch_dual(raw, :permission_denials))
122
+
123
+ ResultMessage.new(
124
+ subtype: raw["subtype"] || "unknown",
125
+ duration_ms: fetch_dual(raw, :duration_ms, 0),
126
+ duration_api_ms: fetch_dual(raw, :duration_api_ms, 0),
127
+ is_error: fetch_dual(raw, :is_error, false),
128
+ num_turns: fetch_dual(raw, :num_turns, 0),
129
+ session_id: fetch_dual(raw, :session_id, ""),
130
+ total_cost_usd: fetch_dual(raw, :total_cost_usd),
131
+ usage: raw["usage"],
132
+ result: raw["result"],
133
+ structured_output: fetch_dual(raw, :structured_output),
134
+ errors: raw["errors"],
135
+ permission_denials: permission_denials,
136
+ model_usage: fetch_dual(raw, :model_usage)
137
+ )
138
+ end
139
+
140
+ def parse_permission_denials(denials)
141
+ return nil unless denials.is_a?(Array)
142
+
143
+ denials.map do |denial|
144
+ SDKPermissionDenial.new(
145
+ tool_name: fetch_dual(denial, :tool_name),
146
+ tool_use_id: fetch_dual(denial, :tool_use_id),
147
+ tool_input: fetch_dual(denial, :tool_input)
148
+ )
149
+ end
150
+ end
151
+
152
+ def parse_stream_event(raw)
153
+ StreamEvent.new(
154
+ uuid: raw["uuid"] || "",
155
+ session_id: fetch_dual(raw, :session_id, ""),
156
+ event: raw["event"] || {},
157
+ parent_tool_use_id: raw["parent_tool_use_id"]
158
+ )
159
+ end
160
+
161
+ def parse_user_content(content)
162
+ case content
163
+ when String
164
+ content
165
+ when Array
166
+ content.map { |block| parse_content_block(block) }
167
+ else
168
+ content.to_s
169
+ end
170
+ end
171
+
172
+ def parse_content_block(block)
173
+ return block unless block.is_a?(Hash)
174
+
175
+ type = block["type"]
176
+
177
+ case type
178
+ when "text"
179
+ TextBlock.new(text: block["text"] || "")
180
+ when "thinking"
181
+ ThinkingBlock.new(
182
+ thinking: block["thinking"] || "",
183
+ signature: block["signature"] || ""
184
+ )
185
+ when "tool_use"
186
+ ToolUseBlock.new(
187
+ id: block["id"] || "",
188
+ name: block["name"] || "",
189
+ input: block["input"] || {}
190
+ )
191
+ when "tool_result"
192
+ ToolResultBlock.new(
193
+ tool_use_id: block["tool_use_id"] || "",
194
+ content: block["content"],
195
+ is_error: block["is_error"]
196
+ )
197
+ when "server_tool_use"
198
+ ServerToolUseBlock.new(
199
+ id: block["id"] || "",
200
+ name: block["name"] || "",
201
+ input: block["input"] || {},
202
+ server_name: block["server_name"] || ""
203
+ )
204
+ when "server_tool_result"
205
+ ServerToolResultBlock.new(
206
+ tool_use_id: block["tool_use_id"] || "",
207
+ content: block["content"],
208
+ is_error: block["is_error"],
209
+ server_name: block["server_name"] || ""
210
+ )
211
+ when "image"
212
+ ImageContentBlock.new(
213
+ source: block["source"] || {}
214
+ )
215
+ else
216
+ # Return raw hash for unknown block types
217
+ block
218
+ end
219
+ end
220
+
221
+ def parse_status_message(raw)
222
+ StatusMessage.new(
223
+ uuid: raw["uuid"] || "",
224
+ session_id: fetch_dual(raw, :session_id, ""),
225
+ status: raw["status"]
226
+ )
227
+ end
228
+
229
+ def parse_tool_progress_message(raw)
230
+ ToolProgressMessage.new(
231
+ uuid: raw["uuid"] || "",
232
+ session_id: fetch_dual(raw, :session_id, ""),
233
+ tool_use_id: fetch_dual(raw, :tool_use_id, ""),
234
+ tool_name: fetch_dual(raw, :tool_name, ""),
235
+ parent_tool_use_id: fetch_dual(raw, :parent_tool_use_id),
236
+ elapsed_time_seconds: fetch_dual(raw, :elapsed_time_seconds, 0)
237
+ )
238
+ end
239
+
240
+ def parse_hook_response_message(raw)
241
+ HookResponseMessage.new(
242
+ uuid: raw["uuid"] || "",
243
+ session_id: fetch_dual(raw, :session_id, ""),
244
+ hook_name: fetch_dual(raw, :hook_name, ""),
245
+ hook_event: fetch_dual(raw, :hook_event, ""),
246
+ stdout: raw["stdout"] || "",
247
+ stderr: raw["stderr"] || "",
248
+ exit_code: fetch_dual(raw, :exit_code)
249
+ )
250
+ end
251
+
252
+ def parse_auth_status_message(raw)
253
+ AuthStatusMessage.new(
254
+ uuid: raw["uuid"] || "",
255
+ session_id: fetch_dual(raw, :session_id, ""),
256
+ is_authenticating: fetch_dual(raw, :is_authenticating, false),
257
+ output: raw["output"] || [],
258
+ error: raw["error"]
259
+ )
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # User message sent to Claude
5
+ #
6
+ # @example
7
+ # msg = UserMessage.new(content: "Hello!", uuid: "abc-123", session_id: "session-abc")
8
+ #
9
+ UserMessage = Data.define(:content, :uuid, :session_id, :parent_tool_use_id) do
10
+ def initialize(content:, uuid: nil, session_id: nil, parent_tool_use_id: nil)
11
+ super
12
+ end
13
+
14
+ def type
15
+ :user
16
+ end
17
+
18
+ # Get text content if content is a string
19
+ # @return [String, nil]
20
+ def text
21
+ content.is_a?(String) ? content : nil
22
+ end
23
+
24
+ # Check if this is a replayed message
25
+ # @return [Boolean]
26
+ def replay?
27
+ false
28
+ end
29
+ end
30
+
31
+ # User message replay (TypeScript SDK parity)
32
+ #
33
+ # Sent when resuming a session with existing conversation history.
34
+ # These messages represent replayed user messages from a previous session.
35
+ #
36
+ # @example
37
+ # msg = UserMessageReplay.new(
38
+ # content: "Hello!",
39
+ # uuid: "abc-123",
40
+ # session_id: "session-abc",
41
+ # is_replay: true
42
+ # )
43
+ # msg.replay? # => true
44
+ #
45
+ UserMessageReplay = Data.define(
46
+ :content,
47
+ :uuid,
48
+ :session_id,
49
+ :parent_tool_use_id,
50
+ :is_replay,
51
+ :is_synthetic,
52
+ :tool_use_result
53
+ ) do
54
+ def initialize(
55
+ content:,
56
+ uuid: nil,
57
+ session_id: nil,
58
+ parent_tool_use_id: nil,
59
+ is_replay: true,
60
+ is_synthetic: nil,
61
+ tool_use_result: nil
62
+ )
63
+ super
64
+ end
65
+
66
+ def type
67
+ :user
68
+ end
69
+
70
+ # Get text content if content is a string
71
+ # @return [String, nil]
72
+ def text
73
+ content.is_a?(String) ? content : nil
74
+ end
75
+
76
+ # Check if this is a replayed message
77
+ # @return [Boolean]
78
+ def replay?
79
+ is_replay == true
80
+ end
81
+
82
+ # Check if this is a synthetic message (system-generated)
83
+ # @return [Boolean]
84
+ def synthetic?
85
+ is_synthetic == true
86
+ end
87
+ end
88
+
89
+ # Assistant message from Claude
90
+ #
91
+ # @example
92
+ # msg = AssistantMessage.new(
93
+ # content: [TextBlock.new(text: "Hello!")],
94
+ # model: "claude-sonnet-4-5-20250514",
95
+ # uuid: "msg-123",
96
+ # session_id: "session-abc"
97
+ # )
98
+ #
99
+ AssistantMessage = Data.define(:content, :model, :uuid, :session_id, :error, :parent_tool_use_id) do
100
+ def initialize(content:, model:, uuid: nil, session_id: nil, error: nil, parent_tool_use_id: nil)
101
+ super
102
+ end
103
+
104
+ def type
105
+ :assistant
106
+ end
107
+
108
+ # Get all text content concatenated
109
+ # @return [String]
110
+ def text
111
+ content
112
+ .select { |block| block.is_a?(TextBlock) }
113
+ .map(&:text)
114
+ .join
115
+ end
116
+
117
+ # Get all thinking content concatenated
118
+ # @return [String]
119
+ def thinking
120
+ content
121
+ .select { |block| block.is_a?(ThinkingBlock) }
122
+ .map(&:thinking)
123
+ .join
124
+ end
125
+
126
+ # Get all tool use blocks
127
+ # @return [Array<ToolUseBlock>]
128
+ def tool_uses
129
+ content.select { |block| block.is_a?(ToolUseBlock) }
130
+ end
131
+
132
+ # Check if assistant wants to use a tool
133
+ # @return [Boolean]
134
+ def has_tool_use?
135
+ content.any? { |block| block.is_a?(ToolUseBlock) }
136
+ end
137
+ end
138
+
139
+ # System message (internal events)
140
+ #
141
+ # @example
142
+ # msg = SystemMessage.new(subtype: "init", data: {version: "2.0.0"})
143
+ #
144
+ SystemMessage = Data.define(:subtype, :data) do
145
+ def type
146
+ :system
147
+ end
148
+ end
149
+
150
+ # Result message (final message with usage/cost info) - TypeScript SDK parity
151
+ #
152
+ # @example Success result
153
+ # msg = ResultMessage.new(
154
+ # subtype: "success",
155
+ # duration_ms: 1500,
156
+ # duration_api_ms: 1200,
157
+ # is_error: false,
158
+ # num_turns: 3,
159
+ # session_id: "session-abc",
160
+ # total_cost_usd: 0.05,
161
+ # usage: {input_tokens: 100, output_tokens: 50}
162
+ # )
163
+ #
164
+ # @example Error result
165
+ # msg = ResultMessage.new(
166
+ # subtype: "error_max_turns",
167
+ # errors: ["Maximum turns exceeded"],
168
+ # ...
169
+ # )
170
+ #
171
+ ResultMessage = Data.define(
172
+ :subtype,
173
+ :duration_ms,
174
+ :duration_api_ms,
175
+ :is_error,
176
+ :num_turns,
177
+ :session_id,
178
+ :total_cost_usd,
179
+ :usage,
180
+ :result,
181
+ :structured_output,
182
+ :errors, # Array<String> for error subtypes
183
+ :permission_denials, # Array<SDKPermissionDenial>
184
+ :model_usage # Hash with per-model usage breakdown
185
+ ) do
186
+ def initialize(
187
+ subtype:,
188
+ duration_ms:,
189
+ duration_api_ms:,
190
+ is_error:,
191
+ num_turns:,
192
+ session_id:,
193
+ total_cost_usd: nil,
194
+ usage: nil,
195
+ result: nil,
196
+ structured_output: nil,
197
+ errors: nil,
198
+ permission_denials: nil,
199
+ model_usage: nil
200
+ )
201
+ super
202
+ end
203
+
204
+ def type
205
+ :result
206
+ end
207
+
208
+ # Check if this was an error result
209
+ # @return [Boolean]
210
+ def error?
211
+ is_error
212
+ end
213
+
214
+ # Check if this was a successful result
215
+ # @return [Boolean]
216
+ def success?
217
+ !is_error
218
+ end
219
+ end
220
+
221
+ # Stream event (partial message during streaming)
222
+ #
223
+ # @example
224
+ # event = StreamEvent.new(
225
+ # uuid: "evt-123",
226
+ # session_id: "session-abc",
227
+ # event: {type: "content_block_delta", delta: {type: "text_delta", text: "Hello"}}
228
+ # )
229
+ #
230
+ StreamEvent = Data.define(:uuid, :session_id, :event, :parent_tool_use_id) do
231
+ def initialize(uuid:, session_id:, event:, parent_tool_use_id: nil)
232
+ super
233
+ end
234
+
235
+ def type
236
+ :stream_event
237
+ end
238
+
239
+ # Get the event type from the raw event
240
+ # @return [String, nil]
241
+ def event_type
242
+ event["type"]
243
+ end
244
+ end
245
+
246
+ # Compact boundary message (conversation compaction marker) - TypeScript SDK parity
247
+ #
248
+ # Sent when the conversation is compacted to reduce context size.
249
+ # Contains metadata about the compaction operation.
250
+ #
251
+ # @example
252
+ # msg = CompactBoundaryMessage.new(
253
+ # uuid: "msg-123",
254
+ # session_id: "session-abc",
255
+ # compact_metadata: { trigger: "auto", pre_tokens: 50000 }
256
+ # )
257
+ # msg.trigger # => "auto"
258
+ # msg.pre_tokens # => 50000
259
+ #
260
+ CompactBoundaryMessage = Data.define(:uuid, :session_id, :compact_metadata) do
261
+ def type
262
+ :compact_boundary
263
+ end
264
+
265
+ # Get the compaction trigger type
266
+ # @return [String] "manual" or "auto"
267
+ def trigger
268
+ compact_metadata[:trigger] || compact_metadata["trigger"]
269
+ end
270
+
271
+ # Get the token count before compaction
272
+ # @return [Integer, nil]
273
+ def pre_tokens
274
+ compact_metadata[:pre_tokens] || compact_metadata["pre_tokens"]
275
+ end
276
+ end
277
+
278
+ # Status message (TypeScript SDK parity)
279
+ #
280
+ # Reports session status like 'compacting' during operations.
281
+ #
282
+ # @example
283
+ # msg = StatusMessage.new(
284
+ # uuid: "msg-123",
285
+ # session_id: "session-abc",
286
+ # status: "compacting"
287
+ # )
288
+ #
289
+ StatusMessage = Data.define(:uuid, :session_id, :status) do
290
+ def type
291
+ :status
292
+ end
293
+ end
294
+
295
+ # Tool progress message (TypeScript SDK parity)
296
+ #
297
+ # Reports progress during long-running tool executions.
298
+ #
299
+ # @example
300
+ # msg = ToolProgressMessage.new(
301
+ # uuid: "msg-123",
302
+ # session_id: "session-abc",
303
+ # tool_use_id: "tool-456",
304
+ # tool_name: "Bash",
305
+ # elapsed_time_seconds: 5.2
306
+ # )
307
+ #
308
+ ToolProgressMessage = Data.define(
309
+ :uuid,
310
+ :session_id,
311
+ :tool_use_id,
312
+ :tool_name,
313
+ :parent_tool_use_id,
314
+ :elapsed_time_seconds
315
+ ) do
316
+ def initialize(
317
+ uuid:,
318
+ session_id:,
319
+ tool_use_id:,
320
+ tool_name:,
321
+ elapsed_time_seconds:,
322
+ parent_tool_use_id: nil
323
+ )
324
+ super
325
+ end
326
+
327
+ def type
328
+ :tool_progress
329
+ end
330
+ end
331
+
332
+ # Hook response message (TypeScript SDK parity)
333
+ #
334
+ # Contains output from hook executions.
335
+ #
336
+ # @example
337
+ # msg = HookResponseMessage.new(
338
+ # uuid: "msg-123",
339
+ # session_id: "session-abc",
340
+ # hook_name: "my-hook",
341
+ # hook_event: "PreToolUse",
342
+ # stdout: "Hook output",
343
+ # stderr: "",
344
+ # exit_code: 0
345
+ # )
346
+ #
347
+ HookResponseMessage = Data.define(
348
+ :uuid,
349
+ :session_id,
350
+ :hook_name,
351
+ :hook_event,
352
+ :stdout,
353
+ :stderr,
354
+ :exit_code
355
+ ) do
356
+ def initialize(
357
+ uuid:,
358
+ session_id:,
359
+ hook_name:,
360
+ hook_event:,
361
+ stdout: "",
362
+ stderr: "",
363
+ exit_code: nil
364
+ )
365
+ super
366
+ end
367
+
368
+ def type
369
+ :hook_response
370
+ end
371
+ end
372
+
373
+ # Auth status message (TypeScript SDK parity)
374
+ #
375
+ # Reports authentication status during login flows.
376
+ #
377
+ # @example
378
+ # msg = AuthStatusMessage.new(
379
+ # uuid: "msg-123",
380
+ # session_id: "session-abc",
381
+ # is_authenticating: true,
382
+ # output: ["Waiting for browser..."]
383
+ # )
384
+ #
385
+ AuthStatusMessage = Data.define(
386
+ :uuid,
387
+ :session_id,
388
+ :is_authenticating,
389
+ :output,
390
+ :error
391
+ ) do
392
+ def initialize(
393
+ uuid:,
394
+ session_id:,
395
+ is_authenticating:,
396
+ output: [],
397
+ error: nil
398
+ )
399
+ super
400
+ end
401
+
402
+ def type
403
+ :auth_status
404
+ end
405
+ end
406
+
407
+ # All message types
408
+ MESSAGE_TYPES = [
409
+ UserMessage,
410
+ UserMessageReplay,
411
+ AssistantMessage,
412
+ SystemMessage,
413
+ ResultMessage,
414
+ StreamEvent,
415
+ CompactBoundaryMessage,
416
+ StatusMessage,
417
+ ToolProgressMessage,
418
+ HookResponseMessage,
419
+ AuthStatusMessage
420
+ ].freeze
421
+ end