Skip to main content

claude_codes/io/
result.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::fmt;
4
5/// Result message for completed queries
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ResultMessage {
8    pub subtype: ResultSubtype,
9    pub is_error: bool,
10    pub duration_ms: u64,
11    pub duration_api_ms: u64,
12
13    /// Time to first token, in milliseconds.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub ttft_ms: Option<u64>,
16
17    /// Time to first streamed token, in milliseconds.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub ttft_stream_ms: Option<u64>,
20
21    /// Time from session start until the first request was issued, in milliseconds.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub time_to_request_ms: Option<u64>,
24
25    /// Time from spawning a worker/spare until the first request was issued, in milliseconds.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub time_to_request_from_spawn_ms: Option<u64>,
28
29    /// Whether a warm spare process was claimed for this request.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub warm_spare_claimed: Option<bool>,
32
33    /// Epoch-ish timestamp origin used by CLI timing instrumentation.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub time_origin_ms: Option<u64>,
36
37    pub num_turns: i32,
38
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub result: Option<String>,
41
42    #[serde(alias = "sessionId")]
43    pub session_id: String,
44    pub total_cost_usd: f64,
45
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub usage: Option<UsageInfo>,
48
49    /// Tools that were blocked due to permission denials during the session
50    #[serde(default)]
51    pub permission_denials: Vec<PermissionDenial>,
52
53    /// Error messages when `is_error` is true.
54    ///
55    /// Contains human-readable error strings (e.g., "No conversation found with session ID: ...").
56    /// This allows typed access to error conditions without needing to serialize to JSON and search.
57    #[serde(default)]
58    pub errors: Vec<String>,
59
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub uuid: Option<String>,
62
63    /// HTTP status code when the result is an API error (e.g., 429, 500, 529)
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub api_error_status: Option<u16>,
66
67    /// Why generation stopped (e.g., end_turn, max_tokens)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub stop_reason: Option<String>,
70
71    /// Why the session ended (e.g., "completed")
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub terminal_reason: Option<String>,
74
75    /// Fast mode toggle state (e.g., "off")
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub fast_mode_state: Option<String>,
78
79    /// Per-model cost breakdown, keyed by model name (e.g. `"claude-opus-4-8"`).
80    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
81    pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
82
83    /// Structured-output payload returned by the model, when enabled.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub structured_output: Option<Value>,
86
87    /// Deferred tool-use termination payload.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub deferred_tool_use: Option<DeferredToolUse>,
90
91    /// Provenance of the message/run.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub origin: Option<super::message_types::MessageOrigin>,
94}
95
96/// Usage and cost for a single model within a session, as found in
97/// [`ResultMessage::model_usage`].
98///
99/// The `extra` field captures any keys the CLI adds that aren't modeled here,
100/// so new wire fields deserialize without error.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct ModelUsageEntry {
104    #[serde(default)]
105    pub input_tokens: u64,
106    #[serde(default)]
107    pub output_tokens: u64,
108    #[serde(default)]
109    pub cache_read_input_tokens: u64,
110    #[serde(default)]
111    pub cache_creation_input_tokens: u64,
112    #[serde(default, rename = "costUSD")]
113    pub cost_usd: f64,
114    #[serde(default)]
115    pub web_search_requests: u32,
116    #[serde(default)]
117    pub context_window: u64,
118    #[serde(default)]
119    pub max_output_tokens: u64,
120    #[serde(flatten)]
121    pub extra: serde_json::Map<String, serde_json::Value>,
122}
123
124/// Tool use deferred by a terminal result.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126pub struct DeferredToolUse {
127    pub id: String,
128    pub name: String,
129    pub input: Value,
130}
131
132/// A record of a tool permission that was denied during the session.
133///
134/// This is included in `ResultMessage.permission_denials` to provide a summary
135/// of all permission denials that occurred.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct PermissionDenial {
138    /// The name of the tool that was blocked (e.g., "Bash", "Write")
139    pub tool_name: String,
140
141    /// The input that was passed to the tool
142    pub tool_input: Value,
143
144    /// The unique identifier for this tool use request
145    pub tool_use_id: String,
146}
147
148/// Result subtypes
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub enum ResultSubtype {
151    Success,
152    ErrorMaxTurns,
153    ErrorDuringExecution,
154    ErrorMaxBudgetUsd,
155    ErrorMaxStructuredOutputRetries,
156    Unknown(String),
157}
158
159impl ResultSubtype {
160    pub fn as_str(&self) -> &str {
161        match self {
162            Self::Success => "success",
163            Self::ErrorMaxTurns => "error_max_turns",
164            Self::ErrorDuringExecution => "error_during_execution",
165            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
166            Self::ErrorMaxStructuredOutputRetries => "error_max_structured_output_retries",
167            Self::Unknown(s) => s.as_str(),
168        }
169    }
170}
171
172impl fmt::Display for ResultSubtype {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(self.as_str())
175    }
176}
177
178impl From<&str> for ResultSubtype {
179    fn from(s: &str) -> Self {
180        match s {
181            "success" => Self::Success,
182            "error_max_turns" => Self::ErrorMaxTurns,
183            "error_during_execution" => Self::ErrorDuringExecution,
184            "error_max_budget_usd" => Self::ErrorMaxBudgetUsd,
185            "error_max_structured_output_retries" => Self::ErrorMaxStructuredOutputRetries,
186            other => Self::Unknown(other.to_string()),
187        }
188    }
189}
190
191impl Serialize for ResultSubtype {
192    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
193        serializer.serialize_str(self.as_str())
194    }
195}
196
197impl<'de> Deserialize<'de> for ResultSubtype {
198    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
199        let s = String::deserialize(deserializer)?;
200        Ok(Self::from(s.as_str()))
201    }
202}
203
204/// Usage information for the request
205///
206/// Note: the `result` frame's usage covers the **main agent only** — the
207/// subagent (`Task` / sidechain) token rollup the CLI renders as
208/// `<subagent_tokens>` / `<agent_count>` is not carried here or anywhere
209/// else on the wire. Accumulate it from `Task` tool results with
210/// [`SubagentUsageRollup`](crate::SubagentUsageRollup).
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct UsageInfo {
213    #[serde(default)]
214    pub input_tokens: u32,
215    #[serde(default)]
216    pub cache_creation_input_tokens: u32,
217    #[serde(default)]
218    pub cache_read_input_tokens: u32,
219    #[serde(default)]
220    pub output_tokens: u32,
221    #[serde(default)]
222    pub server_tool_use: ServerToolUse,
223    #[serde(default)]
224    pub service_tier: String,
225
226    /// Cache creation breakdown
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
229
230    /// Inference geography (e.g., "not_available")
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub inference_geo: Option<String>,
233
234    /// Per-turn usage breakdown
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub iterations: Vec<Value>,
237
238    /// Speed tier (e.g., "standard")
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub speed: Option<String>,
241}
242
243/// Server tool usage information
244#[derive(Debug, Clone, Default, Serialize, Deserialize)]
245pub struct ServerToolUse {
246    #[serde(default)]
247    pub web_search_requests: u32,
248    /// Number of web fetch requests made
249    #[serde(default)]
250    pub web_fetch_requests: u32,
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::io::ClaudeOutput;
257
258    #[test]
259    fn test_deserialize_result_message() {
260        let json = r#"{
261            "type": "result",
262            "subtype": "success",
263            "is_error": false,
264            "duration_ms": 100,
265            "duration_api_ms": 200,
266            "num_turns": 1,
267            "result": "Done",
268            "session_id": "123",
269            "total_cost_usd": 0.01,
270            "permission_denials": []
271        }"#;
272
273        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
274        assert!(!output.is_error());
275    }
276
277    #[test]
278    fn test_result_subtype_new_and_unknown_values_do_not_fail() {
279        let json = r#"{
280            "type": "result",
281            "subtype": "error_max_budget_usd",
282            "is_error": true,
283            "duration_ms": 100,
284            "duration_api_ms": 200,
285            "num_turns": 1,
286            "session_id": "123",
287            "total_cost_usd": 0.01
288        }"#;
289
290        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
291        let ClaudeOutput::Result(result) = output else {
292            panic!("Expected Result");
293        };
294        assert_eq!(result.subtype, ResultSubtype::ErrorMaxBudgetUsd);
295
296        let json = r#"{
297            "type": "result",
298            "subtype": "future_result_subtype",
299            "is_error": true,
300            "duration_ms": 100,
301            "duration_api_ms": 200,
302            "num_turns": 1,
303            "session_id": "123",
304            "total_cost_usd": 0.01
305        }"#;
306
307        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
308        let ClaudeOutput::Result(result) = output else {
309            panic!("Expected Result");
310        };
311        assert_eq!(
312            result.subtype,
313            ResultSubtype::Unknown("future_result_subtype".to_string())
314        );
315    }
316
317    #[test]
318    fn test_deserialize_result_with_permission_denials() {
319        let json = r#"{
320            "type": "result",
321            "subtype": "success",
322            "is_error": false,
323            "duration_ms": 100,
324            "duration_api_ms": 200,
325            "num_turns": 2,
326            "result": "Done",
327            "session_id": "123",
328            "total_cost_usd": 0.01,
329            "permission_denials": [
330                {
331                    "tool_name": "Bash",
332                    "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
333                    "tool_use_id": "toolu_123"
334                }
335            ]
336        }"#;
337
338        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
339        if let ClaudeOutput::Result(result) = output {
340            assert_eq!(result.permission_denials.len(), 1);
341            assert_eq!(result.permission_denials[0].tool_name, "Bash");
342            assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
343            assert_eq!(
344                result.permission_denials[0]
345                    .tool_input
346                    .get("command")
347                    .unwrap(),
348                "rm -rf /"
349            );
350        } else {
351            panic!("Expected Result");
352        }
353    }
354
355    #[test]
356    fn test_permission_denial_roundtrip() {
357        let denial = PermissionDenial {
358            tool_name: "Write".to_string(),
359            tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
360            tool_use_id: "toolu_456".to_string(),
361        };
362
363        let json = serde_json::to_string(&denial).unwrap();
364        assert!(json.contains("\"tool_name\":\"Write\""));
365        assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
366        assert!(json.contains("/etc/passwd"));
367
368        let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
369        assert_eq!(parsed, denial);
370    }
371
372    #[test]
373    fn test_deserialize_result_message_with_errors() {
374        let json = r#"{
375            "type": "result",
376            "subtype": "error_during_execution",
377            "duration_ms": 0,
378            "duration_api_ms": 0,
379            "is_error": true,
380            "num_turns": 0,
381            "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
382            "total_cost_usd": 0,
383            "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
384            "permission_denials": []
385        }"#;
386
387        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
388        assert!(output.is_error());
389
390        if let ClaudeOutput::Result(res) = output {
391            assert!(res.is_error);
392            assert_eq!(res.errors.len(), 1);
393            assert!(res.errors[0].contains("No conversation found"));
394        } else {
395            panic!("Expected Result message");
396        }
397    }
398
399    #[test]
400    fn test_deserialize_result_message_errors_defaults_empty() {
401        let json = r#"{
402            "type": "result",
403            "subtype": "success",
404            "is_error": false,
405            "duration_ms": 100,
406            "duration_api_ms": 200,
407            "num_turns": 1,
408            "session_id": "123",
409            "total_cost_usd": 0.01
410        }"#;
411
412        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
413        if let ClaudeOutput::Result(res) = output {
414            assert!(res.errors.is_empty());
415        } else {
416            panic!("Expected Result message");
417        }
418    }
419
420    #[test]
421    fn test_result_message_errors_roundtrip() {
422        let json = r#"{
423            "type": "result",
424            "subtype": "error_during_execution",
425            "is_error": true,
426            "duration_ms": 0,
427            "duration_api_ms": 0,
428            "num_turns": 0,
429            "session_id": "test-session",
430            "total_cost_usd": 0.0,
431            "errors": ["Error 1", "Error 2"]
432        }"#;
433
434        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
435        let reserialized = serde_json::to_string(&output).unwrap();
436
437        assert!(reserialized.contains("Error 1"));
438        assert!(reserialized.contains("Error 2"));
439    }
440
441    #[test]
442    fn test_result_with_new_fields() {
443        let json = r#"{
444            "type": "result",
445            "subtype": "success",
446            "is_error": false,
447            "duration_ms": 5000,
448            "duration_api_ms": 4500,
449            "num_turns": 1,
450            "result": "Done",
451            "session_id": "abc",
452            "total_cost_usd": 0.06,
453            "api_error_status": null,
454            "stop_reason": "end_turn",
455            "terminal_reason": "completed",
456            "fast_mode_state": "off",
457            "modelUsage": {
458                "claude-opus-4-7[1m]": {
459                    "inputTokens": 3817,
460                    "outputTokens": 14,
461                    "costUSD": 0.06
462                }
463            },
464            "usage": {
465                "input_tokens": 3817,
466                "output_tokens": 14,
467                "cache_creation_input_tokens": 3540,
468                "cache_read_input_tokens": 0,
469                "server_tool_use": {
470                    "web_search_requests": 0,
471                    "web_fetch_requests": 2
472                },
473                "service_tier": "standard",
474                "inference_geo": "not_available",
475                "speed": "standard",
476                "iterations": [
477                    {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
478                ]
479            }
480        }"#;
481
482        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
483        if let ClaudeOutput::Result(res) = output {
484            assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
485            assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
486            assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
487            let model_usage = res.model_usage.as_ref().unwrap();
488            let entry = model_usage
489                .get("claude-opus-4-7[1m]")
490                .expect("per-model entry present");
491            assert_eq!(entry.input_tokens, 3817);
492            assert_eq!(entry.output_tokens, 14);
493            assert_eq!(entry.cost_usd, 0.06);
494            assert!(res.api_error_status.is_none());
495
496            let usage = res.usage.unwrap();
497            assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
498            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
499            assert_eq!(usage.speed.as_deref(), Some("standard"));
500            assert_eq!(usage.iterations.len(), 1);
501        } else {
502            panic!("Expected Result");
503        }
504    }
505
506    #[test]
507    fn test_result_backwards_compatible_without_new_fields() {
508        // Verify old-format messages still parse fine
509        let json = r#"{
510            "type": "result",
511            "subtype": "success",
512            "is_error": false,
513            "duration_ms": 100,
514            "duration_api_ms": 200,
515            "num_turns": 1,
516            "session_id": "abc",
517            "total_cost_usd": 0.01
518        }"#;
519
520        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
521        if let ClaudeOutput::Result(res) = output {
522            assert!(res.api_error_status.is_none());
523            assert!(res.stop_reason.is_none());
524            assert!(res.terminal_reason.is_none());
525            assert!(res.fast_mode_state.is_none());
526            assert!(res.model_usage.is_none());
527        } else {
528            panic!("Expected Result");
529        }
530    }
531}