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 (e.g. `"claude-opus-4-8"`).
53    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
54    pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
55}
56
57/// Usage and cost for a single model within a session, as found in
58/// [`ResultMessage::model_usage`].
59///
60/// The `extra` field captures any keys the CLI adds that aren't modeled here,
61/// so new wire fields deserialize without error.
62#[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/// A record of a tool permission that was denied during the session.
82///
83/// This is included in `ResultMessage.permission_denials` to provide a summary
84/// of all permission denials that occurred.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct PermissionDenial {
87    /// The name of the tool that was blocked (e.g., "Bash", "Write")
88    pub tool_name: String,
89
90    /// The input that was passed to the tool
91    pub tool_input: Value,
92
93    /// The unique identifier for this tool use request
94    pub tool_use_id: String,
95}
96
97/// Result subtypes
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ResultSubtype {
101    Success,
102    ErrorMaxTurns,
103    ErrorDuringExecution,
104}
105
106/// Usage information for the request
107#[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    /// Cache creation breakdown
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
125
126    /// Inference geography (e.g., "not_available")
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub inference_geo: Option<String>,
129
130    /// Per-turn usage breakdown
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub iterations: Vec<Value>,
133
134    /// Speed tier (e.g., "standard")
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub speed: Option<String>,
137}
138
139/// Server tool usage information
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141pub struct ServerToolUse {
142    #[serde(default)]
143    pub web_search_requests: u32,
144    /// Number of web fetch requests made
145    #[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        // Verify old-format messages still parse fine
365        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}