1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::fmt;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ResultMessage {
8 pub subtype: ResultSubtype,
9 pub is_error: bool,
10 pub duration_ms: u64,
11 pub duration_api_ms: u64,
12
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub ttft_ms: Option<u64>,
16
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub ttft_stream_ms: Option<u64>,
20
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub time_to_request_ms: Option<u64>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub time_to_request_from_spawn_ms: Option<u64>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub warm_spare_claimed: Option<bool>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub time_origin_ms: Option<u64>,
36
37 pub num_turns: i32,
38
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub result: Option<String>,
41
42 #[serde(alias = "sessionId")]
43 pub session_id: String,
44 pub total_cost_usd: f64,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub usage: Option<UsageInfo>,
48
49 #[serde(default)]
51 pub permission_denials: Vec<PermissionDenial>,
52
53 #[serde(default)]
58 pub errors: Vec<String>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub uuid: Option<String>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub api_error_status: Option<u16>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub stop_reason: Option<String>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub terminal_reason: Option<String>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub fast_mode_state: Option<String>,
78
79 #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
81 pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub structured_output: Option<Value>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub deferred_tool_use: Option<DeferredToolUse>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub origin: Option<super::message_types::MessageOrigin>,
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ModelUsageEntry {
104 #[serde(default)]
105 pub input_tokens: u64,
106 #[serde(default)]
107 pub output_tokens: u64,
108 #[serde(default)]
109 pub cache_read_input_tokens: u64,
110 #[serde(default)]
111 pub cache_creation_input_tokens: u64,
112 #[serde(default, rename = "costUSD")]
113 pub cost_usd: f64,
114 #[serde(default)]
115 pub web_search_requests: u32,
116 #[serde(default)]
117 pub context_window: u64,
118 #[serde(default)]
119 pub max_output_tokens: u64,
120 #[serde(flatten)]
121 pub extra: serde_json::Map<String, serde_json::Value>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126pub struct DeferredToolUse {
127 pub id: String,
128 pub name: String,
129 pub input: Value,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct PermissionDenial {
138 pub tool_name: String,
140
141 pub tool_input: Value,
143
144 pub tool_use_id: String,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub enum ResultSubtype {
151 Success,
152 ErrorMaxTurns,
153 ErrorDuringExecution,
154 ErrorMaxBudgetUsd,
155 ErrorMaxStructuredOutputRetries,
156 Unknown(String),
157}
158
159impl ResultSubtype {
160 pub fn as_str(&self) -> &str {
161 match self {
162 Self::Success => "success",
163 Self::ErrorMaxTurns => "error_max_turns",
164 Self::ErrorDuringExecution => "error_during_execution",
165 Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
166 Self::ErrorMaxStructuredOutputRetries => "error_max_structured_output_retries",
167 Self::Unknown(s) => s.as_str(),
168 }
169 }
170}
171
172impl fmt::Display for ResultSubtype {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.write_str(self.as_str())
175 }
176}
177
178impl From<&str> for ResultSubtype {
179 fn from(s: &str) -> Self {
180 match s {
181 "success" => Self::Success,
182 "error_max_turns" => Self::ErrorMaxTurns,
183 "error_during_execution" => Self::ErrorDuringExecution,
184 "error_max_budget_usd" => Self::ErrorMaxBudgetUsd,
185 "error_max_structured_output_retries" => Self::ErrorMaxStructuredOutputRetries,
186 other => Self::Unknown(other.to_string()),
187 }
188 }
189}
190
191impl Serialize for ResultSubtype {
192 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
193 serializer.serialize_str(self.as_str())
194 }
195}
196
197impl<'de> Deserialize<'de> for ResultSubtype {
198 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
199 let s = String::deserialize(deserializer)?;
200 Ok(Self::from(s.as_str()))
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct UsageInfo {
213 #[serde(default)]
214 pub input_tokens: u32,
215 #[serde(default)]
216 pub cache_creation_input_tokens: u32,
217 #[serde(default)]
218 pub cache_read_input_tokens: u32,
219 #[serde(default)]
220 pub output_tokens: u32,
221 #[serde(default)]
222 pub server_tool_use: ServerToolUse,
223 #[serde(default)]
224 pub service_tier: String,
225
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub cache_creation: Option<super::message_types::CacheCreationDetails>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub inference_geo: Option<String>,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub iterations: Vec<Value>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub speed: Option<String>,
241}
242
243#[derive(Debug, Clone, Default, Serialize, Deserialize)]
245pub struct ServerToolUse {
246 #[serde(default)]
247 pub web_search_requests: u32,
248 #[serde(default)]
250 pub web_fetch_requests: u32,
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::io::ClaudeOutput;
257
258 #[test]
259 fn test_deserialize_result_message() {
260 let json = r#"{
261 "type": "result",
262 "subtype": "success",
263 "is_error": false,
264 "duration_ms": 100,
265 "duration_api_ms": 200,
266 "num_turns": 1,
267 "result": "Done",
268 "session_id": "123",
269 "total_cost_usd": 0.01,
270 "permission_denials": []
271 }"#;
272
273 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
274 assert!(!output.is_error());
275 }
276
277 #[test]
278 fn test_result_subtype_new_and_unknown_values_do_not_fail() {
279 let json = r#"{
280 "type": "result",
281 "subtype": "error_max_budget_usd",
282 "is_error": true,
283 "duration_ms": 100,
284 "duration_api_ms": 200,
285 "num_turns": 1,
286 "session_id": "123",
287 "total_cost_usd": 0.01
288 }"#;
289
290 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
291 let ClaudeOutput::Result(result) = output else {
292 panic!("Expected Result");
293 };
294 assert_eq!(result.subtype, ResultSubtype::ErrorMaxBudgetUsd);
295
296 let json = r#"{
297 "type": "result",
298 "subtype": "future_result_subtype",
299 "is_error": true,
300 "duration_ms": 100,
301 "duration_api_ms": 200,
302 "num_turns": 1,
303 "session_id": "123",
304 "total_cost_usd": 0.01
305 }"#;
306
307 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
308 let ClaudeOutput::Result(result) = output else {
309 panic!("Expected Result");
310 };
311 assert_eq!(
312 result.subtype,
313 ResultSubtype::Unknown("future_result_subtype".to_string())
314 );
315 }
316
317 #[test]
318 fn test_deserialize_result_with_permission_denials() {
319 let json = r#"{
320 "type": "result",
321 "subtype": "success",
322 "is_error": false,
323 "duration_ms": 100,
324 "duration_api_ms": 200,
325 "num_turns": 2,
326 "result": "Done",
327 "session_id": "123",
328 "total_cost_usd": 0.01,
329 "permission_denials": [
330 {
331 "tool_name": "Bash",
332 "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
333 "tool_use_id": "toolu_123"
334 }
335 ]
336 }"#;
337
338 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
339 if let ClaudeOutput::Result(result) = output {
340 assert_eq!(result.permission_denials.len(), 1);
341 assert_eq!(result.permission_denials[0].tool_name, "Bash");
342 assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
343 assert_eq!(
344 result.permission_denials[0]
345 .tool_input
346 .get("command")
347 .unwrap(),
348 "rm -rf /"
349 );
350 } else {
351 panic!("Expected Result");
352 }
353 }
354
355 #[test]
356 fn test_permission_denial_roundtrip() {
357 let denial = PermissionDenial {
358 tool_name: "Write".to_string(),
359 tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
360 tool_use_id: "toolu_456".to_string(),
361 };
362
363 let json = serde_json::to_string(&denial).unwrap();
364 assert!(json.contains("\"tool_name\":\"Write\""));
365 assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
366 assert!(json.contains("/etc/passwd"));
367
368 let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
369 assert_eq!(parsed, denial);
370 }
371
372 #[test]
373 fn test_deserialize_result_message_with_errors() {
374 let json = r#"{
375 "type": "result",
376 "subtype": "error_during_execution",
377 "duration_ms": 0,
378 "duration_api_ms": 0,
379 "is_error": true,
380 "num_turns": 0,
381 "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
382 "total_cost_usd": 0,
383 "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
384 "permission_denials": []
385 }"#;
386
387 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
388 assert!(output.is_error());
389
390 if let ClaudeOutput::Result(res) = output {
391 assert!(res.is_error);
392 assert_eq!(res.errors.len(), 1);
393 assert!(res.errors[0].contains("No conversation found"));
394 } else {
395 panic!("Expected Result message");
396 }
397 }
398
399 #[test]
400 fn test_deserialize_result_message_errors_defaults_empty() {
401 let json = r#"{
402 "type": "result",
403 "subtype": "success",
404 "is_error": false,
405 "duration_ms": 100,
406 "duration_api_ms": 200,
407 "num_turns": 1,
408 "session_id": "123",
409 "total_cost_usd": 0.01
410 }"#;
411
412 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
413 if let ClaudeOutput::Result(res) = output {
414 assert!(res.errors.is_empty());
415 } else {
416 panic!("Expected Result message");
417 }
418 }
419
420 #[test]
421 fn test_result_message_errors_roundtrip() {
422 let json = r#"{
423 "type": "result",
424 "subtype": "error_during_execution",
425 "is_error": true,
426 "duration_ms": 0,
427 "duration_api_ms": 0,
428 "num_turns": 0,
429 "session_id": "test-session",
430 "total_cost_usd": 0.0,
431 "errors": ["Error 1", "Error 2"]
432 }"#;
433
434 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
435 let reserialized = serde_json::to_string(&output).unwrap();
436
437 assert!(reserialized.contains("Error 1"));
438 assert!(reserialized.contains("Error 2"));
439 }
440
441 #[test]
442 fn test_result_with_new_fields() {
443 let json = r#"{
444 "type": "result",
445 "subtype": "success",
446 "is_error": false,
447 "duration_ms": 5000,
448 "duration_api_ms": 4500,
449 "num_turns": 1,
450 "result": "Done",
451 "session_id": "abc",
452 "total_cost_usd": 0.06,
453 "api_error_status": null,
454 "stop_reason": "end_turn",
455 "terminal_reason": "completed",
456 "fast_mode_state": "off",
457 "modelUsage": {
458 "claude-opus-4-7[1m]": {
459 "inputTokens": 3817,
460 "outputTokens": 14,
461 "costUSD": 0.06
462 }
463 },
464 "usage": {
465 "input_tokens": 3817,
466 "output_tokens": 14,
467 "cache_creation_input_tokens": 3540,
468 "cache_read_input_tokens": 0,
469 "server_tool_use": {
470 "web_search_requests": 0,
471 "web_fetch_requests": 2
472 },
473 "service_tier": "standard",
474 "inference_geo": "not_available",
475 "speed": "standard",
476 "iterations": [
477 {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
478 ]
479 }
480 }"#;
481
482 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
483 if let ClaudeOutput::Result(res) = output {
484 assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
485 assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
486 assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
487 let model_usage = res.model_usage.as_ref().unwrap();
488 let entry = model_usage
489 .get("claude-opus-4-7[1m]")
490 .expect("per-model entry present");
491 assert_eq!(entry.input_tokens, 3817);
492 assert_eq!(entry.output_tokens, 14);
493 assert_eq!(entry.cost_usd, 0.06);
494 assert!(res.api_error_status.is_none());
495
496 let usage = res.usage.unwrap();
497 assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
498 assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
499 assert_eq!(usage.speed.as_deref(), Some("standard"));
500 assert_eq!(usage.iterations.len(), 1);
501 } else {
502 panic!("Expected Result");
503 }
504 }
505
506 #[test]
507 fn test_result_backwards_compatible_without_new_fields() {
508 let json = r#"{
510 "type": "result",
511 "subtype": "success",
512 "is_error": false,
513 "duration_ms": 100,
514 "duration_api_ms": 200,
515 "num_turns": 1,
516 "session_id": "abc",
517 "total_cost_usd": 0.01
518 }"#;
519
520 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
521 if let ClaudeOutput::Result(res) = output {
522 assert!(res.api_error_status.is_none());
523 assert!(res.stop_reason.is_none());
524 assert!(res.terminal_reason.is_none());
525 assert!(res.fast_mode_state.is_none());
526 assert!(res.model_usage.is_none());
527 } else {
528 panic!("Expected Result");
529 }
530 }
531}