Skip to main content

claude_codes/io/
result.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// Result message for completed queries
5#[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    /// Tools that were blocked due to permission denials during the session
23    #[serde(default)]
24    pub permission_denials: Vec<PermissionDenial>,
25
26    /// Error messages when `is_error` is true.
27    ///
28    /// Contains human-readable error strings (e.g., "No conversation found with session ID: ...").
29    /// This allows typed access to error conditions without needing to serialize to JSON and search.
30    #[serde(default)]
31    pub errors: Vec<String>,
32
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub uuid: Option<String>,
35
36    /// HTTP status code when the result is an API error (e.g., 429, 500, 529)
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub api_error_status: Option<u16>,
39
40    /// Why generation stopped (e.g., end_turn, max_tokens)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub stop_reason: Option<String>,
43
44    /// Why the session ended (e.g., "completed")
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub terminal_reason: Option<String>,
47
48    /// Fast mode toggle state (e.g., "off")
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub fast_mode_state: Option<String>,
51
52    /// Per-model cost breakdown, keyed by model name
53    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
54    pub model_usage: Option<Value>,
55}
56
57/// A record of a tool permission that was denied during the session.
58///
59/// This is included in `ResultMessage.permission_denials` to provide a summary
60/// of all permission denials that occurred.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct PermissionDenial {
63    /// The name of the tool that was blocked (e.g., "Bash", "Write")
64    pub tool_name: String,
65
66    /// The input that was passed to the tool
67    pub tool_input: Value,
68
69    /// The unique identifier for this tool use request
70    pub tool_use_id: String,
71}
72
73/// Result subtypes
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ResultSubtype {
77    Success,
78    ErrorMaxTurns,
79    ErrorDuringExecution,
80}
81
82/// Usage information for the request
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct UsageInfo {
85    #[serde(default)]
86    pub input_tokens: u32,
87    #[serde(default)]
88    pub cache_creation_input_tokens: u32,
89    #[serde(default)]
90    pub cache_read_input_tokens: u32,
91    #[serde(default)]
92    pub output_tokens: u32,
93    #[serde(default)]
94    pub server_tool_use: ServerToolUse,
95    #[serde(default)]
96    pub service_tier: String,
97
98    /// Cache creation breakdown
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
101
102    /// Inference geography (e.g., "not_available")
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub inference_geo: Option<String>,
105
106    /// Per-turn usage breakdown
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub iterations: Vec<Value>,
109
110    /// Speed tier (e.g., "standard")
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub speed: Option<String>,
113}
114
115/// Server tool usage information
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct ServerToolUse {
118    #[serde(default)]
119    pub web_search_requests: u32,
120    /// Number of web fetch requests made
121    #[serde(default)]
122    pub web_fetch_requests: u32,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::io::ClaudeOutput;
129
130    #[test]
131    fn test_deserialize_result_message() {
132        let json = r#"{
133            "type": "result",
134            "subtype": "success",
135            "is_error": false,
136            "duration_ms": 100,
137            "duration_api_ms": 200,
138            "num_turns": 1,
139            "result": "Done",
140            "session_id": "123",
141            "total_cost_usd": 0.01,
142            "permission_denials": []
143        }"#;
144
145        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
146        assert!(!output.is_error());
147    }
148
149    #[test]
150    fn test_deserialize_result_with_permission_denials() {
151        let json = r#"{
152            "type": "result",
153            "subtype": "success",
154            "is_error": false,
155            "duration_ms": 100,
156            "duration_api_ms": 200,
157            "num_turns": 2,
158            "result": "Done",
159            "session_id": "123",
160            "total_cost_usd": 0.01,
161            "permission_denials": [
162                {
163                    "tool_name": "Bash",
164                    "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
165                    "tool_use_id": "toolu_123"
166                }
167            ]
168        }"#;
169
170        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
171        if let ClaudeOutput::Result(result) = output {
172            assert_eq!(result.permission_denials.len(), 1);
173            assert_eq!(result.permission_denials[0].tool_name, "Bash");
174            assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
175            assert_eq!(
176                result.permission_denials[0]
177                    .tool_input
178                    .get("command")
179                    .unwrap(),
180                "rm -rf /"
181            );
182        } else {
183            panic!("Expected Result");
184        }
185    }
186
187    #[test]
188    fn test_permission_denial_roundtrip() {
189        let denial = PermissionDenial {
190            tool_name: "Write".to_string(),
191            tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
192            tool_use_id: "toolu_456".to_string(),
193        };
194
195        let json = serde_json::to_string(&denial).unwrap();
196        assert!(json.contains("\"tool_name\":\"Write\""));
197        assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
198        assert!(json.contains("/etc/passwd"));
199
200        let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
201        assert_eq!(parsed, denial);
202    }
203
204    #[test]
205    fn test_deserialize_result_message_with_errors() {
206        let json = r#"{
207            "type": "result",
208            "subtype": "error_during_execution",
209            "duration_ms": 0,
210            "duration_api_ms": 0,
211            "is_error": true,
212            "num_turns": 0,
213            "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
214            "total_cost_usd": 0,
215            "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
216            "permission_denials": []
217        }"#;
218
219        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
220        assert!(output.is_error());
221
222        if let ClaudeOutput::Result(res) = output {
223            assert!(res.is_error);
224            assert_eq!(res.errors.len(), 1);
225            assert!(res.errors[0].contains("No conversation found"));
226        } else {
227            panic!("Expected Result message");
228        }
229    }
230
231    #[test]
232    fn test_deserialize_result_message_errors_defaults_empty() {
233        let json = r#"{
234            "type": "result",
235            "subtype": "success",
236            "is_error": false,
237            "duration_ms": 100,
238            "duration_api_ms": 200,
239            "num_turns": 1,
240            "session_id": "123",
241            "total_cost_usd": 0.01
242        }"#;
243
244        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
245        if let ClaudeOutput::Result(res) = output {
246            assert!(res.errors.is_empty());
247        } else {
248            panic!("Expected Result message");
249        }
250    }
251
252    #[test]
253    fn test_result_message_errors_roundtrip() {
254        let json = r#"{
255            "type": "result",
256            "subtype": "error_during_execution",
257            "is_error": true,
258            "duration_ms": 0,
259            "duration_api_ms": 0,
260            "num_turns": 0,
261            "session_id": "test-session",
262            "total_cost_usd": 0.0,
263            "errors": ["Error 1", "Error 2"]
264        }"#;
265
266        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
267        let reserialized = serde_json::to_string(&output).unwrap();
268
269        assert!(reserialized.contains("Error 1"));
270        assert!(reserialized.contains("Error 2"));
271    }
272
273    #[test]
274    fn test_result_with_new_fields() {
275        let json = r#"{
276            "type": "result",
277            "subtype": "success",
278            "is_error": false,
279            "duration_ms": 5000,
280            "duration_api_ms": 4500,
281            "num_turns": 1,
282            "result": "Done",
283            "session_id": "abc",
284            "total_cost_usd": 0.06,
285            "api_error_status": null,
286            "stop_reason": "end_turn",
287            "terminal_reason": "completed",
288            "fast_mode_state": "off",
289            "modelUsage": {
290                "claude-opus-4-7[1m]": {
291                    "inputTokens": 3817,
292                    "outputTokens": 14,
293                    "costUSD": 0.06
294                }
295            },
296            "usage": {
297                "input_tokens": 3817,
298                "output_tokens": 14,
299                "cache_creation_input_tokens": 3540,
300                "cache_read_input_tokens": 0,
301                "server_tool_use": {
302                    "web_search_requests": 0,
303                    "web_fetch_requests": 2
304                },
305                "service_tier": "standard",
306                "inference_geo": "not_available",
307                "speed": "standard",
308                "iterations": [
309                    {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
310                ]
311            }
312        }"#;
313
314        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
315        if let ClaudeOutput::Result(res) = output {
316            assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
317            assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
318            assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
319            assert!(res.model_usage.is_some());
320            assert!(res.api_error_status.is_none());
321
322            let usage = res.usage.unwrap();
323            assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
324            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
325            assert_eq!(usage.speed.as_deref(), Some("standard"));
326            assert_eq!(usage.iterations.len(), 1);
327        } else {
328            panic!("Expected Result");
329        }
330    }
331
332    #[test]
333    fn test_result_backwards_compatible_without_new_fields() {
334        // Verify old-format messages still parse fine
335        let json = r#"{
336            "type": "result",
337            "subtype": "success",
338            "is_error": false,
339            "duration_ms": 100,
340            "duration_api_ms": 200,
341            "num_turns": 1,
342            "session_id": "abc",
343            "total_cost_usd": 0.01
344        }"#;
345
346        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
347        if let ClaudeOutput::Result(res) = output {
348            assert!(res.api_error_status.is_none());
349            assert!(res.stop_reason.is_none());
350            assert!(res.terminal_reason.is_none());
351            assert!(res.fast_mode_state.is_none());
352            assert!(res.model_usage.is_none());
353        } else {
354            panic!("Expected Result");
355        }
356    }
357}