1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ResultMessage {
7 pub subtype: ResultSubtype,
8 pub is_error: bool,
9 pub duration_ms: u64,
10 pub duration_api_ms: u64,
11
12 #[serde(skip_serializing_if = "Option::is_none")]
14 pub ttft_ms: Option<u64>,
15
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub ttft_stream_ms: Option<u64>,
19
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub time_to_request_ms: Option<u64>,
23
24 pub num_turns: i32,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub result: Option<String>,
28
29 pub session_id: String,
30 pub total_cost_usd: f64,
31
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub usage: Option<UsageInfo>,
34
35 #[serde(default)]
37 pub permission_denials: Vec<PermissionDenial>,
38
39 #[serde(default)]
44 pub errors: Vec<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub uuid: Option<String>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub api_error_status: Option<u16>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub stop_reason: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub terminal_reason: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub fast_mode_state: Option<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
67 pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ModelUsageEntry {
78 #[serde(default)]
79 pub input_tokens: u64,
80 #[serde(default)]
81 pub output_tokens: u64,
82 #[serde(default)]
83 pub cache_read_input_tokens: u64,
84 #[serde(default)]
85 pub cache_creation_input_tokens: u64,
86 #[serde(default, rename = "costUSD")]
87 pub cost_usd: f64,
88 #[serde(default)]
89 pub web_search_requests: u32,
90 #[serde(flatten)]
91 pub extra: serde_json::Map<String, serde_json::Value>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct PermissionDenial {
100 pub tool_name: String,
102
103 pub tool_input: Value,
105
106 pub tool_use_id: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ResultSubtype {
114 Success,
115 ErrorMaxTurns,
116 ErrorDuringExecution,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct UsageInfo {
122 #[serde(default)]
123 pub input_tokens: u32,
124 #[serde(default)]
125 pub cache_creation_input_tokens: u32,
126 #[serde(default)]
127 pub cache_read_input_tokens: u32,
128 #[serde(default)]
129 pub output_tokens: u32,
130 #[serde(default)]
131 pub server_tool_use: ServerToolUse,
132 #[serde(default)]
133 pub service_tier: String,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub cache_creation: Option<super::message_types::CacheCreationDetails>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub inference_geo: Option<String>,
142
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub iterations: Vec<Value>,
146
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub speed: Option<String>,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct ServerToolUse {
155 #[serde(default)]
156 pub web_search_requests: u32,
157 #[serde(default)]
159 pub web_fetch_requests: u32,
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::io::ClaudeOutput;
166
167 #[test]
168 fn test_deserialize_result_message() {
169 let json = r#"{
170 "type": "result",
171 "subtype": "success",
172 "is_error": false,
173 "duration_ms": 100,
174 "duration_api_ms": 200,
175 "num_turns": 1,
176 "result": "Done",
177 "session_id": "123",
178 "total_cost_usd": 0.01,
179 "permission_denials": []
180 }"#;
181
182 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
183 assert!(!output.is_error());
184 }
185
186 #[test]
187 fn test_deserialize_result_with_permission_denials() {
188 let json = r#"{
189 "type": "result",
190 "subtype": "success",
191 "is_error": false,
192 "duration_ms": 100,
193 "duration_api_ms": 200,
194 "num_turns": 2,
195 "result": "Done",
196 "session_id": "123",
197 "total_cost_usd": 0.01,
198 "permission_denials": [
199 {
200 "tool_name": "Bash",
201 "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
202 "tool_use_id": "toolu_123"
203 }
204 ]
205 }"#;
206
207 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
208 if let ClaudeOutput::Result(result) = output {
209 assert_eq!(result.permission_denials.len(), 1);
210 assert_eq!(result.permission_denials[0].tool_name, "Bash");
211 assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
212 assert_eq!(
213 result.permission_denials[0]
214 .tool_input
215 .get("command")
216 .unwrap(),
217 "rm -rf /"
218 );
219 } else {
220 panic!("Expected Result");
221 }
222 }
223
224 #[test]
225 fn test_permission_denial_roundtrip() {
226 let denial = PermissionDenial {
227 tool_name: "Write".to_string(),
228 tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
229 tool_use_id: "toolu_456".to_string(),
230 };
231
232 let json = serde_json::to_string(&denial).unwrap();
233 assert!(json.contains("\"tool_name\":\"Write\""));
234 assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
235 assert!(json.contains("/etc/passwd"));
236
237 let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
238 assert_eq!(parsed, denial);
239 }
240
241 #[test]
242 fn test_deserialize_result_message_with_errors() {
243 let json = r#"{
244 "type": "result",
245 "subtype": "error_during_execution",
246 "duration_ms": 0,
247 "duration_api_ms": 0,
248 "is_error": true,
249 "num_turns": 0,
250 "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
251 "total_cost_usd": 0,
252 "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
253 "permission_denials": []
254 }"#;
255
256 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
257 assert!(output.is_error());
258
259 if let ClaudeOutput::Result(res) = output {
260 assert!(res.is_error);
261 assert_eq!(res.errors.len(), 1);
262 assert!(res.errors[0].contains("No conversation found"));
263 } else {
264 panic!("Expected Result message");
265 }
266 }
267
268 #[test]
269 fn test_deserialize_result_message_errors_defaults_empty() {
270 let json = r#"{
271 "type": "result",
272 "subtype": "success",
273 "is_error": false,
274 "duration_ms": 100,
275 "duration_api_ms": 200,
276 "num_turns": 1,
277 "session_id": "123",
278 "total_cost_usd": 0.01
279 }"#;
280
281 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
282 if let ClaudeOutput::Result(res) = output {
283 assert!(res.errors.is_empty());
284 } else {
285 panic!("Expected Result message");
286 }
287 }
288
289 #[test]
290 fn test_result_message_errors_roundtrip() {
291 let json = r#"{
292 "type": "result",
293 "subtype": "error_during_execution",
294 "is_error": true,
295 "duration_ms": 0,
296 "duration_api_ms": 0,
297 "num_turns": 0,
298 "session_id": "test-session",
299 "total_cost_usd": 0.0,
300 "errors": ["Error 1", "Error 2"]
301 }"#;
302
303 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
304 let reserialized = serde_json::to_string(&output).unwrap();
305
306 assert!(reserialized.contains("Error 1"));
307 assert!(reserialized.contains("Error 2"));
308 }
309
310 #[test]
311 fn test_result_with_new_fields() {
312 let json = r#"{
313 "type": "result",
314 "subtype": "success",
315 "is_error": false,
316 "duration_ms": 5000,
317 "duration_api_ms": 4500,
318 "num_turns": 1,
319 "result": "Done",
320 "session_id": "abc",
321 "total_cost_usd": 0.06,
322 "api_error_status": null,
323 "stop_reason": "end_turn",
324 "terminal_reason": "completed",
325 "fast_mode_state": "off",
326 "modelUsage": {
327 "claude-opus-4-7[1m]": {
328 "inputTokens": 3817,
329 "outputTokens": 14,
330 "costUSD": 0.06
331 }
332 },
333 "usage": {
334 "input_tokens": 3817,
335 "output_tokens": 14,
336 "cache_creation_input_tokens": 3540,
337 "cache_read_input_tokens": 0,
338 "server_tool_use": {
339 "web_search_requests": 0,
340 "web_fetch_requests": 2
341 },
342 "service_tier": "standard",
343 "inference_geo": "not_available",
344 "speed": "standard",
345 "iterations": [
346 {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
347 ]
348 }
349 }"#;
350
351 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
352 if let ClaudeOutput::Result(res) = output {
353 assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
354 assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
355 assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
356 let model_usage = res.model_usage.as_ref().unwrap();
357 let entry = model_usage
358 .get("claude-opus-4-7[1m]")
359 .expect("per-model entry present");
360 assert_eq!(entry.input_tokens, 3817);
361 assert_eq!(entry.output_tokens, 14);
362 assert_eq!(entry.cost_usd, 0.06);
363 assert!(res.api_error_status.is_none());
364
365 let usage = res.usage.unwrap();
366 assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
367 assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
368 assert_eq!(usage.speed.as_deref(), Some("standard"));
369 assert_eq!(usage.iterations.len(), 1);
370 } else {
371 panic!("Expected Result");
372 }
373 }
374
375 #[test]
376 fn test_result_backwards_compatible_without_new_fields() {
377 let json = r#"{
379 "type": "result",
380 "subtype": "success",
381 "is_error": false,
382 "duration_ms": 100,
383 "duration_api_ms": 200,
384 "num_turns": 1,
385 "session_id": "abc",
386 "total_cost_usd": 0.01
387 }"#;
388
389 let output: ClaudeOutput = serde_json::from_str(json).unwrap();
390 if let ClaudeOutput::Result(res) = output {
391 assert!(res.api_error_status.is_none());
392 assert!(res.stop_reason.is_none());
393 assert!(res.terminal_reason.is_none());
394 assert!(res.fast_mode_state.is_none());
395 assert!(res.model_usage.is_none());
396 } else {
397 panic!("Expected Result");
398 }
399 }
400}