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