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
12    /// Time to first token, in milliseconds.
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub ttft_ms: Option<u64>,
15
16    /// Time to first streamed token, in milliseconds.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub ttft_stream_ms: Option<u64>,
19
20    /// Time from session start until the first request was issued, in milliseconds.
21    #[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    /// Tools that were blocked due to permission denials during the session
36    #[serde(default)]
37    pub permission_denials: Vec<PermissionDenial>,
38
39    /// Error messages when `is_error` is true.
40    ///
41    /// Contains human-readable error strings (e.g., "No conversation found with session ID: ...").
42    /// This allows typed access to error conditions without needing to serialize to JSON and search.
43    #[serde(default)]
44    pub errors: Vec<String>,
45
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub uuid: Option<String>,
48
49    /// HTTP status code when the result is an API error (e.g., 429, 500, 529)
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub api_error_status: Option<u16>,
52
53    /// Why generation stopped (e.g., end_turn, max_tokens)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub stop_reason: Option<String>,
56
57    /// Why the session ended (e.g., "completed")
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub terminal_reason: Option<String>,
60
61    /// Fast mode toggle state (e.g., "off")
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub fast_mode_state: Option<String>,
64
65    /// Per-model cost breakdown, keyed by model name (e.g. `"claude-opus-4-8"`).
66    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
67    pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
68}
69
70/// Usage and cost for a single model within a session, as found in
71/// [`ResultMessage::model_usage`].
72///
73/// The `extra` field captures any keys the CLI adds that aren't modeled here,
74/// so new wire fields deserialize without error.
75#[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/// A record of a tool permission that was denied during the session.
95///
96/// This is included in `ResultMessage.permission_denials` to provide a summary
97/// of all permission denials that occurred.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct PermissionDenial {
100    /// The name of the tool that was blocked (e.g., "Bash", "Write")
101    pub tool_name: String,
102
103    /// The input that was passed to the tool
104    pub tool_input: Value,
105
106    /// The unique identifier for this tool use request
107    pub tool_use_id: String,
108}
109
110/// Result subtypes
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ResultSubtype {
114    Success,
115    ErrorMaxTurns,
116    ErrorDuringExecution,
117}
118
119/// Usage information for the request
120#[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    /// Cache creation breakdown
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
138
139    /// Inference geography (e.g., "not_available")
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub inference_geo: Option<String>,
142
143    /// Per-turn usage breakdown
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub iterations: Vec<Value>,
146
147    /// Speed tier (e.g., "standard")
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub speed: Option<String>,
150}
151
152/// Server tool usage information
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct ServerToolUse {
155    #[serde(default)]
156    pub web_search_requests: u32,
157    /// Number of web fetch requests made
158    #[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        // Verify old-format messages still parse fine
378        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}