claude_agent 0.7.7 → 0.7.9

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.
@@ -3,6 +3,10 @@
3
3
  module ClaudeAgent
4
4
  # Parses raw JSON messages from the CLI into typed message objects
5
5
  #
6
+ # Uses a registry to route messages by type (and subtype for system messages)
7
+ # to named parse methods. New message types can be added with a single
8
+ # {.register} call instead of modifying case statements.
9
+ #
6
10
  # @example
7
11
  # parser = MessageParser.new
8
12
  # message = parser.parse({"type" => "assistant", "message" => {...}})
@@ -13,93 +17,94 @@ module ClaudeAgent
13
17
  @logger = logger
14
18
  end
15
19
 
20
+ # Registry of parser methods keyed by routing string.
21
+ # Keys are "type" for top-level types, "type:subtype" for system subtypes.
22
+ @registry = {}
23
+
24
+ class << self
25
+ attr_reader :registry
26
+
27
+ # Register a parse method for a message type
28
+ #
29
+ # @param key [String] Routing key ("type" or "type:subtype")
30
+ # @param method_name [Symbol] Name of the private parse method
31
+ def register(key, method_name)
32
+ @registry[key] = method_name
33
+ end
34
+ end
35
+
16
36
  # Parse a raw message hash into a typed message object
17
37
  #
18
- # @param raw [Hash] Raw message from CLI
38
+ # @param raw [Hash] Raw message from CLI (string or symbol keys, camelCase or snake_case)
19
39
  # @return [UserMessage, UserMessageReplay, AssistantMessage, SystemMessage, ResultMessage, StreamEvent, CompactBoundaryMessage, StatusMessage, ToolProgressMessage, HookResponseMessage, AuthStatusMessage, TaskNotificationMessage, HookStartedMessage, HookProgressMessage, ToolUseSummaryMessage, FilesPersistedEvent, TaskStartedMessage, RateLimitEvent, PromptSuggestionMessage]
20
40
  # @raise [MessageParseError] If message cannot be parsed
21
41
  def parse(raw)
22
- type = raw["type"]
42
+ raw = raw.deep_transform_keys { |key| key.to_s.underscore.to_sym }
43
+ type = raw[:type]
23
44
  logger.debug("parser") { "Parsing message: #{type}" }
24
45
 
25
- case type
26
- when "user"
27
- parse_user_message(raw)
28
- when "assistant"
29
- parse_assistant_message(raw)
30
- when "system"
31
- # Check for special system subtypes
32
- case raw["subtype"]
33
- when "compact_boundary"
34
- parse_compact_boundary_message(raw)
35
- when "status"
36
- parse_status_message(raw)
37
- when "hook_response"
38
- parse_hook_response_message(raw)
39
- when "task_notification"
40
- parse_task_notification_message(raw)
41
- when "hook_started"
42
- parse_hook_started_message(raw)
43
- when "hook_progress"
44
- parse_hook_progress_message(raw)
45
- when "files_persisted"
46
- parse_files_persisted_event(raw)
47
- when "task_started"
48
- parse_task_started_message(raw)
49
- else
50
- parse_system_message(raw)
51
- end
52
- when "result"
53
- parse_result_message(raw)
54
- when "stream_event"
55
- parse_stream_event(raw)
56
- when "tool_progress"
57
- parse_tool_progress_message(raw)
58
- when "auth_status"
59
- parse_auth_status_message(raw)
60
- when "tool_use_summary"
61
- parse_tool_use_summary_message(raw)
62
- when "rate_limit_event"
63
- parse_rate_limit_event(raw)
64
- when "prompt_suggestion"
65
- parse_prompt_suggestion_message(raw)
46
+ # Look up parser: try specific system subtype first, then top-level type
47
+ method_name = if type == "system" && raw[:subtype]
48
+ self.class.registry["system:#{raw[:subtype]}"] || self.class.registry["system"]
66
49
  else
67
- logger.error("parser") { "Unknown message type: #{type}" }
68
- raise MessageParseError.new("Unknown message type: #{type}", raw_message: raw)
50
+ self.class.registry[type]
51
+ end
52
+
53
+ if method_name
54
+ send(method_name, raw)
55
+ else
56
+ logger.warn("parser") { "Unknown message type: #{type}, wrapping in GenericMessage" }
57
+ GenericMessage.new(message_type: type.to_s, raw: raw)
69
58
  end
70
59
  end
71
60
 
61
+ # --- Parser Registration ---
62
+ #
63
+ # Top-level message types
64
+
65
+ register "user", :parse_user_message
66
+ register "assistant", :parse_assistant_message
67
+ register "result", :parse_result_message
68
+ register "stream_event", :parse_stream_event
69
+ register "tool_progress", :parse_tool_progress_message
70
+ register "auth_status", :parse_auth_status_message
71
+ register "tool_use_summary", :parse_tool_use_summary_message
72
+ register "rate_limit_event", :parse_rate_limit_event
73
+ register "prompt_suggestion", :parse_prompt_suggestion_message
74
+
75
+ # System message subtypes
76
+
77
+ register "system", :parse_system_message # fallback for unknown subtypes
78
+ register "system:compact_boundary", :parse_compact_boundary_message
79
+ register "system:status", :parse_status_message
80
+ register "system:hook_response", :parse_hook_response_message
81
+ register "system:task_notification", :parse_task_notification_message
82
+ register "system:hook_started", :parse_hook_started_message
83
+ register "system:hook_progress", :parse_hook_progress_message
84
+ register "system:files_persisted", :parse_files_persisted_event
85
+ register "system:task_started", :parse_task_started_message
86
+ register "system:task_progress", :parse_task_progress_message
87
+
72
88
  private
73
89
 
74
90
  def logger
75
91
  @logger || ClaudeAgent.logger
76
92
  end
77
93
 
78
- # Fetch a value from a hash, trying both snake_case and camelCase keys
79
- # @param raw [Hash] The hash to fetch from
80
- # @param snake_key [Symbol, String] The snake_case key to try
81
- # @param default [Object] Default value if neither key exists
82
- # @return [Object] The value or default
83
- def fetch_dual(raw, snake_key, default = nil)
84
- snake_str = snake_key.to_s
85
- camel_str = snake_str.camelize(:lower)
86
- raw[snake_str] || raw[camel_str] || default
87
- end
88
-
89
94
  def parse_user_message(raw)
90
- message = raw["message"] || {}
91
- content = parse_user_content(message["content"])
95
+ message = raw[:message] || {}
96
+ content = parse_user_content(message[:content])
92
97
 
93
- is_replay = fetch_dual(raw, :is_replay)
94
- is_synthetic = fetch_dual(raw, :is_synthetic)
95
- tool_use_result = fetch_dual(raw, :tool_use_result)
98
+ is_replay = raw[:is_replay]
99
+ is_synthetic = raw[:is_synthetic]
100
+ tool_use_result = raw[:tool_use_result]
96
101
 
97
102
  if is_replay
98
103
  UserMessageReplay.new(
99
104
  content: content,
100
- uuid: raw["uuid"],
101
- session_id: fetch_dual(raw, :session_id),
102
- parent_tool_use_id: raw["parent_tool_use_id"],
105
+ uuid: raw[:uuid],
106
+ session_id: raw[:session_id],
107
+ parent_tool_use_id: raw[:parent_tool_use_id],
103
108
  is_replay: true,
104
109
  is_synthetic: is_synthetic,
105
110
  tool_use_result: tool_use_result
@@ -107,61 +112,61 @@ module ClaudeAgent
107
112
  else
108
113
  UserMessage.new(
109
114
  content: content,
110
- uuid: raw["uuid"],
111
- session_id: fetch_dual(raw, :session_id),
112
- parent_tool_use_id: raw["parent_tool_use_id"]
115
+ uuid: raw[:uuid],
116
+ session_id: raw[:session_id],
117
+ parent_tool_use_id: raw[:parent_tool_use_id]
113
118
  )
114
119
  end
115
120
  end
116
121
 
117
122
  def parse_assistant_message(raw)
118
- message = raw["message"] || {}
119
- content_raw = message["content"] || []
123
+ message = raw[:message] || {}
124
+ content_raw = message[:content] || []
120
125
  content = content_raw.map { |block| parse_content_block(block) }
121
126
 
122
127
  AssistantMessage.new(
123
128
  content: content,
124
- model: message["model"] || raw["model"] || "unknown",
125
- uuid: raw["uuid"],
126
- session_id: fetch_dual(raw, :session_id),
127
- error: message["error"] || raw["error"],
128
- parent_tool_use_id: raw["parent_tool_use_id"]
129
+ model: message[:model] || raw[:model] || "unknown",
130
+ uuid: raw[:uuid],
131
+ session_id: raw[:session_id],
132
+ error: message[:error] || raw[:error],
133
+ parent_tool_use_id: raw[:parent_tool_use_id]
129
134
  )
130
135
  end
131
136
 
132
137
  def parse_system_message(raw)
133
138
  SystemMessage.new(
134
- subtype: raw["subtype"] || "unknown",
135
- data: raw["data"] || raw
139
+ subtype: raw[:subtype] || "unknown",
140
+ data: raw[:data] || raw
136
141
  )
137
142
  end
138
143
 
139
144
  def parse_compact_boundary_message(raw)
140
145
  CompactBoundaryMessage.new(
141
- uuid: raw["uuid"] || "",
142
- session_id: fetch_dual(raw, :session_id, ""),
143
- compact_metadata: fetch_dual(raw, :compact_metadata, {})
146
+ uuid: raw[:uuid] || "",
147
+ session_id: raw[:session_id] || "",
148
+ compact_metadata: raw[:compact_metadata] || {}
144
149
  )
145
150
  end
146
151
 
147
152
  def parse_result_message(raw)
148
- permission_denials = parse_permission_denials(fetch_dual(raw, :permission_denials))
153
+ permission_denials = parse_permission_denials(raw[:permission_denials])
149
154
 
150
155
  ResultMessage.new(
151
- subtype: raw["subtype"] || "unknown",
152
- duration_ms: fetch_dual(raw, :duration_ms, 0),
153
- duration_api_ms: fetch_dual(raw, :duration_api_ms, 0),
154
- is_error: fetch_dual(raw, :is_error, false),
155
- num_turns: fetch_dual(raw, :num_turns, 0),
156
- session_id: fetch_dual(raw, :session_id, ""),
157
- total_cost_usd: fetch_dual(raw, :total_cost_usd),
158
- usage: raw["usage"],
159
- result: raw["result"],
160
- structured_output: fetch_dual(raw, :structured_output),
161
- errors: raw["errors"],
156
+ subtype: raw[:subtype] || "unknown",
157
+ duration_ms: raw[:duration_ms] || 0,
158
+ duration_api_ms: raw[:duration_api_ms] || 0,
159
+ is_error: raw[:is_error] || false,
160
+ num_turns: raw[:num_turns] || 0,
161
+ session_id: raw[:session_id] || "",
162
+ total_cost_usd: raw[:total_cost_usd],
163
+ usage: raw[:usage],
164
+ result: raw[:result],
165
+ structured_output: raw[:structured_output],
166
+ errors: raw[:errors],
162
167
  permission_denials: permission_denials,
163
- model_usage: fetch_dual(raw, :model_usage),
164
- stop_reason: fetch_dual(raw, :stop_reason)
168
+ model_usage: raw[:model_usage],
169
+ stop_reason: raw[:stop_reason]
165
170
  )
166
171
  end
167
172
 
@@ -170,19 +175,19 @@ module ClaudeAgent
170
175
 
171
176
  denials.map do |denial|
172
177
  SDKPermissionDenial.new(
173
- tool_name: fetch_dual(denial, :tool_name),
174
- tool_use_id: fetch_dual(denial, :tool_use_id),
175
- tool_input: fetch_dual(denial, :tool_input)
178
+ tool_name: denial[:tool_name],
179
+ tool_use_id: denial[:tool_use_id],
180
+ tool_input: denial[:tool_input]
176
181
  )
177
182
  end
178
183
  end
179
184
 
180
185
  def parse_stream_event(raw)
181
186
  StreamEvent.new(
182
- uuid: raw["uuid"] || "",
183
- session_id: fetch_dual(raw, :session_id, ""),
184
- event: raw["event"] || {},
185
- parent_tool_use_id: raw["parent_tool_use_id"]
187
+ uuid: raw[:uuid] || "",
188
+ session_id: raw[:session_id] || "",
189
+ event: raw[:event] || {},
190
+ parent_tool_use_id: raw[:parent_tool_use_id]
186
191
  )
187
192
  end
188
193
 
@@ -200,173 +205,184 @@ module ClaudeAgent
200
205
  def parse_content_block(block)
201
206
  return block unless block.is_a?(Hash)
202
207
 
203
- type = block["type"]
208
+ type = block[:type]
204
209
 
205
210
  case type
206
211
  when "text"
207
- TextBlock.new(text: block["text"] || "")
212
+ TextBlock.new(text: block[:text] || "")
208
213
  when "thinking"
209
214
  ThinkingBlock.new(
210
- thinking: block["thinking"] || "",
211
- signature: block["signature"] || ""
215
+ thinking: block[:thinking] || "",
216
+ signature: block[:signature] || ""
212
217
  )
213
218
  when "tool_use"
214
219
  ToolUseBlock.new(
215
- id: block["id"] || "",
216
- name: block["name"] || "",
217
- input: block["input"] || {}
220
+ id: block[:id] || "",
221
+ name: block[:name] || "",
222
+ input: block[:input] || {}
218
223
  )
219
224
  when "tool_result"
220
225
  ToolResultBlock.new(
221
- tool_use_id: block["tool_use_id"] || "",
222
- content: block["content"],
223
- is_error: block["is_error"]
226
+ tool_use_id: block[:tool_use_id] || "",
227
+ content: block[:content],
228
+ is_error: block[:is_error]
224
229
  )
225
230
  when "server_tool_use"
226
231
  ServerToolUseBlock.new(
227
- id: block["id"] || "",
228
- name: block["name"] || "",
229
- input: block["input"] || {},
230
- server_name: block["server_name"] || ""
232
+ id: block[:id] || "",
233
+ name: block[:name] || "",
234
+ input: block[:input] || {},
235
+ server_name: block[:server_name] || ""
231
236
  )
232
237
  when "server_tool_result"
233
238
  ServerToolResultBlock.new(
234
- tool_use_id: block["tool_use_id"] || "",
235
- content: block["content"],
236
- is_error: block["is_error"],
237
- server_name: block["server_name"] || ""
239
+ tool_use_id: block[:tool_use_id] || "",
240
+ content: block[:content],
241
+ is_error: block[:is_error],
242
+ server_name: block[:server_name] || ""
238
243
  )
239
244
  when "image"
240
245
  ImageContentBlock.new(
241
- source: block["source"] || {}
246
+ source: block[:source] || {}
242
247
  )
243
248
  else
244
- # Return raw hash for unknown block types
245
- block
249
+ GenericBlock.new(block_type: type.to_s, raw: block)
246
250
  end
247
251
  end
248
252
 
249
253
  def parse_status_message(raw)
250
254
  StatusMessage.new(
251
- uuid: raw["uuid"] || "",
252
- session_id: fetch_dual(raw, :session_id, ""),
253
- status: raw["status"]
255
+ uuid: raw[:uuid] || "",
256
+ session_id: raw[:session_id] || "",
257
+ status: raw[:status]
254
258
  )
255
259
  end
256
260
 
257
261
  def parse_tool_progress_message(raw)
258
262
  ToolProgressMessage.new(
259
- uuid: raw["uuid"] || "",
260
- session_id: fetch_dual(raw, :session_id, ""),
261
- tool_use_id: fetch_dual(raw, :tool_use_id, ""),
262
- tool_name: fetch_dual(raw, :tool_name, ""),
263
- parent_tool_use_id: fetch_dual(raw, :parent_tool_use_id),
264
- elapsed_time_seconds: fetch_dual(raw, :elapsed_time_seconds, 0)
263
+ uuid: raw[:uuid] || "",
264
+ session_id: raw[:session_id] || "",
265
+ tool_use_id: raw[:tool_use_id] || "",
266
+ tool_name: raw[:tool_name] || "",
267
+ parent_tool_use_id: raw[:parent_tool_use_id],
268
+ elapsed_time_seconds: raw[:elapsed_time_seconds] || 0
265
269
  )
266
270
  end
267
271
 
268
272
  def parse_hook_response_message(raw)
269
273
  HookResponseMessage.new(
270
- uuid: raw["uuid"] || "",
271
- session_id: fetch_dual(raw, :session_id, ""),
272
- hook_id: fetch_dual(raw, :hook_id),
273
- hook_name: fetch_dual(raw, :hook_name, ""),
274
- hook_event: fetch_dual(raw, :hook_event, ""),
275
- stdout: raw["stdout"] || "",
276
- stderr: raw["stderr"] || "",
277
- output: raw["output"] || "",
278
- exit_code: fetch_dual(raw, :exit_code),
279
- outcome: raw["outcome"]
274
+ uuid: raw[:uuid] || "",
275
+ session_id: raw[:session_id] || "",
276
+ hook_id: raw[:hook_id],
277
+ hook_name: raw[:hook_name] || "",
278
+ hook_event: raw[:hook_event] || "",
279
+ stdout: raw[:stdout] || "",
280
+ stderr: raw[:stderr] || "",
281
+ output: raw[:output] || "",
282
+ exit_code: raw[:exit_code],
283
+ outcome: raw[:outcome]
280
284
  )
281
285
  end
282
286
 
283
287
  def parse_auth_status_message(raw)
284
288
  AuthStatusMessage.new(
285
- uuid: raw["uuid"] || "",
286
- session_id: fetch_dual(raw, :session_id, ""),
287
- is_authenticating: fetch_dual(raw, :is_authenticating, false),
288
- output: raw["output"] || [],
289
- error: raw["error"]
289
+ uuid: raw[:uuid] || "",
290
+ session_id: raw[:session_id] || "",
291
+ is_authenticating: raw[:is_authenticating] || false,
292
+ output: raw[:output] || [],
293
+ error: raw[:error]
290
294
  )
291
295
  end
292
296
 
293
297
  def parse_task_notification_message(raw)
294
298
  TaskNotificationMessage.new(
295
- uuid: raw["uuid"] || "",
296
- session_id: fetch_dual(raw, :session_id, ""),
297
- task_id: fetch_dual(raw, :task_id, ""),
298
- status: raw["status"] || "unknown",
299
- output_file: fetch_dual(raw, :output_file, ""),
300
- summary: raw["summary"] || ""
299
+ uuid: raw[:uuid] || "",
300
+ session_id: raw[:session_id] || "",
301
+ task_id: raw[:task_id] || "",
302
+ status: raw[:status] || "unknown",
303
+ output_file: raw[:output_file] || "",
304
+ summary: raw[:summary] || ""
301
305
  )
302
306
  end
303
307
 
304
308
  def parse_hook_started_message(raw)
305
309
  HookStartedMessage.new(
306
- uuid: raw["uuid"] || "",
307
- session_id: fetch_dual(raw, :session_id, ""),
308
- hook_id: fetch_dual(raw, :hook_id, ""),
309
- hook_name: fetch_dual(raw, :hook_name, ""),
310
- hook_event: fetch_dual(raw, :hook_event, "")
310
+ uuid: raw[:uuid] || "",
311
+ session_id: raw[:session_id] || "",
312
+ hook_id: raw[:hook_id] || "",
313
+ hook_name: raw[:hook_name] || "",
314
+ hook_event: raw[:hook_event] || ""
311
315
  )
312
316
  end
313
317
 
314
318
  def parse_hook_progress_message(raw)
315
319
  HookProgressMessage.new(
316
- uuid: raw["uuid"] || "",
317
- session_id: fetch_dual(raw, :session_id, ""),
318
- hook_id: fetch_dual(raw, :hook_id, ""),
319
- hook_name: fetch_dual(raw, :hook_name, ""),
320
- hook_event: fetch_dual(raw, :hook_event, ""),
321
- stdout: raw["stdout"] || "",
322
- stderr: raw["stderr"] || "",
323
- output: raw["output"] || ""
320
+ uuid: raw[:uuid] || "",
321
+ session_id: raw[:session_id] || "",
322
+ hook_id: raw[:hook_id] || "",
323
+ hook_name: raw[:hook_name] || "",
324
+ hook_event: raw[:hook_event] || "",
325
+ stdout: raw[:stdout] || "",
326
+ stderr: raw[:stderr] || "",
327
+ output: raw[:output] || ""
324
328
  )
325
329
  end
326
330
 
327
331
  def parse_tool_use_summary_message(raw)
328
332
  ToolUseSummaryMessage.new(
329
- uuid: raw["uuid"] || "",
330
- session_id: fetch_dual(raw, :session_id, ""),
331
- summary: raw["summary"] || "",
332
- preceding_tool_use_ids: fetch_dual(raw, :preceding_tool_use_ids, [])
333
+ uuid: raw[:uuid] || "",
334
+ session_id: raw[:session_id] || "",
335
+ summary: raw[:summary] || "",
336
+ preceding_tool_use_ids: raw[:preceding_tool_use_ids] || []
333
337
  )
334
338
  end
335
339
 
336
340
  def parse_files_persisted_event(raw)
337
341
  FilesPersistedEvent.new(
338
- uuid: raw["uuid"] || "",
339
- session_id: fetch_dual(raw, :session_id, ""),
340
- files: raw["files"] || [],
341
- failed: raw["failed"] || [],
342
- processed_at: fetch_dual(raw, :processed_at)
342
+ uuid: raw[:uuid] || "",
343
+ session_id: raw[:session_id] || "",
344
+ files: raw[:files] || [],
345
+ failed: raw[:failed] || [],
346
+ processed_at: raw[:processed_at]
343
347
  )
344
348
  end
345
349
 
346
350
  def parse_task_started_message(raw)
347
351
  TaskStartedMessage.new(
348
- uuid: raw["uuid"] || "",
349
- session_id: fetch_dual(raw, :session_id, ""),
350
- task_id: fetch_dual(raw, :task_id, ""),
351
- tool_use_id: fetch_dual(raw, :tool_use_id),
352
- description: raw["description"],
353
- task_type: fetch_dual(raw, :task_type)
352
+ uuid: raw[:uuid] || "",
353
+ session_id: raw[:session_id] || "",
354
+ task_id: raw[:task_id] || "",
355
+ tool_use_id: raw[:tool_use_id],
356
+ description: raw[:description],
357
+ task_type: raw[:task_type]
358
+ )
359
+ end
360
+
361
+ def parse_task_progress_message(raw)
362
+ TaskProgressMessage.new(
363
+ uuid: raw[:uuid] || "",
364
+ session_id: raw[:session_id] || "",
365
+ task_id: raw[:task_id] || "",
366
+ tool_use_id: raw[:tool_use_id],
367
+ description: raw[:description] || "",
368
+ usage: raw[:usage],
369
+ last_tool_name: raw[:last_tool_name]
354
370
  )
355
371
  end
356
372
 
357
373
  def parse_rate_limit_event(raw)
358
374
  RateLimitEvent.new(
359
- rate_limit_info: fetch_dual(raw, :rate_limit_info, {}),
360
- uuid: raw["uuid"] || "",
361
- session_id: fetch_dual(raw, :session_id, "")
375
+ rate_limit_info: raw[:rate_limit_info] || {},
376
+ uuid: raw[:uuid] || "",
377
+ session_id: raw[:session_id] || ""
362
378
  )
363
379
  end
364
380
 
365
381
  def parse_prompt_suggestion_message(raw)
366
382
  PromptSuggestionMessage.new(
367
- uuid: raw["uuid"] || "",
368
- session_id: fetch_dual(raw, :session_id, ""),
369
- suggestion: raw["suggestion"] || ""
383
+ uuid: raw[:uuid] || "",
384
+ session_id: raw[:session_id] || "",
385
+ suggestion: raw[:suggestion] || ""
370
386
  )
371
387
  end
372
388
  end