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    /// Wall-clock epoch milliseconds when the API request was sent
38    /// (fractional; from CLI timing instrumentation).
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub request_sent_wall_ms: Option<f64>,
41
42    /// Time until the first content frame of the stream arrived, in
43    /// milliseconds (CLI 2.1.260+ timing instrumentation).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub first_content_frame_ms: Option<u64>,
46
47    /// Time until the first stream POST was issued, in milliseconds
48    /// (CLI 2.1.260+).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub first_stream_post_ms: Option<u64>,
51
52    /// Time until the first stream POST was acknowledged, in milliseconds
53    /// (CLI 2.1.260+).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub first_stream_post_ack_ms: Option<u64>,
56
57    /// Time the first stream POST spent waiting in the outbound queue, in
58    /// milliseconds (CLI 2.1.278+).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub first_stream_post_queue_wait_ms: Option<u64>,
61
62    /// What the first stream POST was queued behind (CLI 2.1.278+).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub first_stream_post_queued_behind: Option<StreamPostQueuedBehind>,
65
66    /// Wall-clock epoch milliseconds when the first stream POST was issued
67    /// (fractional; CLI 2.1.260+).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub first_stream_post_wall_ms: Option<f64>,
70
71    /// Wall-clock epoch milliseconds when the triggering send's frame arrived
72    /// on a session reading its input from the session server's SSE stream
73    /// (`--sdk-url`), before the input loop read it (CLI 2.1.278+). With
74    /// [`frame_enqueued_wall_ms`](Self::frame_enqueued_wall_ms) and
75    /// [`turn_started_wall_ms`](Self::turn_started_wall_ms) it splits the
76    /// stretch between the server's persist and `request_sent_wall_ms` into
77    /// transit, the input loop's handling of the frame, its wait on the
78    /// command queue, and the turn's own work. Present only together with
79    /// `request_sent_wall_ms`, and only when the transport recorded the
80    /// receipt; absent on a local stdin host.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub frame_received_wall_ms: Option<f64>,
83
84    /// Wall-clock epoch milliseconds when the input loop put the triggering
85    /// send's frame on the command queue (CLI 2.1.278+). Present exactly
86    /// when [`frame_received_wall_ms`](Self::frame_received_wall_ms) is.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub frame_enqueued_wall_ms: Option<f64>,
89
90    /// Wall-clock epoch milliseconds when the turn's clock started: the
91    /// anchor `duration_ms`, `ttft_ms`, `ttft_stream_ms`,
92    /// `time_to_request_ms`, `first_content_frame_ms`, `first_stream_post_ms`
93    /// and `first_stream_post_ack_ms` are measured from (CLI 2.1.278+).
94    /// Present exactly when
95    /// [`frame_received_wall_ms`](Self::frame_received_wall_ms) is.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub turn_started_wall_ms: Option<f64>,
98
99    /// Time until the first text POST was issued, in milliseconds
100    /// (CLI 2.1.278+).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub first_text_post_ms: Option<u64>,
103
104    /// Wall-clock epoch milliseconds when the first text POST was issued
105    /// (fractional; CLI 2.1.278+).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub first_text_post_wall_ms: Option<f64>,
108
109    /// Wire uuid of the user message this result answers.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub user_message_uuid: Option<String>,
112
113    /// Client uuids of every user message whose prompt this turn consumed, in
114    /// consumption order — all members of a prompt batch the host merged into
115    /// this one turn, plus any queued user message folded into the running
116    /// turn between tool rounds. Always contains `user_message_uuid`; at most
117    /// 64 entries. Absent on delivery-failure/zeroed results and from CLIs
118    /// before 2.1.259 (fall back to `user_message_uuid`).
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub user_message_uuids: Vec<String>,
121
122    /// Why this turn was the automatic re-run of a turn a worker restart
123    /// interrupted (`CLAUDE_CODE_RESUME_INTERRUPTED_TURN`): the host's
124    /// `CLAUDE_CODE_RESUME_REASON` when it set one (`host_draining`,
125    /// `checkpoint_restore`, `container_recreated`, ...), else
126    /// `interrupted_turn`. Present on a headless re-run's result, success or
127    /// error; absent on every other turn, on the Remote Control bridge's
128    /// per-turn synthetic results, and from CLIs before 2.1.268.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub resume_reason: Option<String>,
131
132    /// The local slash command that produced this result when the query loop
133    /// was bypassed (success results only). Absent when the turn went to the
134    /// model and from CLIs before 2.1.268.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub local_command: Option<String>,
137
138    /// User-initiated sends still waiting in the command queue when this
139    /// result was produced. Greater than 0 means at least one more user turn
140    /// follows without further input, barring cancellation. Absent on fatal
141    /// startup results, on surfaces without a command queue, and from CLIs
142    /// before 2.1.259.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub queued_turn_count: Option<u64>,
145
146    pub num_turns: i32,
147
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub result: Option<String>,
150
151    #[serde(alias = "sessionId")]
152    pub session_id: String,
153    pub total_cost_usd: f64,
154
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub usage: Option<UsageInfo>,
157
158    /// Tools that were blocked due to permission denials during the session
159    #[serde(default)]
160    pub permission_denials: Vec<PermissionDenial>,
161
162    /// Error messages when `is_error` is true.
163    ///
164    /// Contains human-readable error strings (e.g., "No conversation found with session ID: ...").
165    /// This allows typed access to error conditions without needing to serialize to JSON and search.
166    #[serde(default)]
167    pub errors: Vec<String>,
168
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub uuid: Option<String>,
171
172    /// HTTP status code when the result is an API error (e.g., 429, 500, 529)
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub api_error_status: Option<u16>,
175
176    /// The `api_error_code` of the API error that ended the turn (see
177    /// `AssistantMessage::api_error_code`): the server's
178    /// `error.details.error_code` when it is an identifier. Absent when the
179    /// turn did not end on an API error, when the response carried no code,
180    /// and from CLIs before 2.1.274.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub api_error_code: Option<String>,
183
184    /// Why generation stopped (e.g., end_turn, max_tokens)
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub stop_reason: Option<String>,
187
188    /// Why the session ended (e.g., "completed")
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub terminal_reason: Option<String>,
191
192    /// Delivery sequence of this result within the run: how many results the
193    /// run numbered before this one, starting at 0, in the order the process
194    /// writes them. A result whose write fails still consumes its number, so
195    /// a gap means a result was lost. Distinct from `num_turns`. Numbered by
196    /// the process hosting the run (`claude -p`); a local client relaying a
197    /// cloud session passes the cloud numbering through and its own locally
198    /// built error results carry none. Absent from CLIs before 2.1.268.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub result_index: Option<u64>,
201
202    /// Fast mode toggle state (e.g., "off")
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub fast_mode_state: Option<String>,
205
206    /// Why fast mode can't serve right now. Absent when nothing blocks it.
207    /// A paused-after-rate-limit run is not reported here; it rides
208    /// `fast_mode_state` as `"cooldown"`.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub fast_mode_disabled_reason: Option<FastModeDisabledReason>,
211
212    /// Per-model cost breakdown, keyed by model name (e.g. `"claude-opus-4-8"`).
213    #[serde(skip_serializing_if = "Option::is_none", rename = "modelUsage")]
214    pub model_usage: Option<std::collections::BTreeMap<String, ModelUsageEntry>>,
215
216    /// Subagents started through the Agent tool in this session, as running
217    /// totals. Cumulative like `modelUsage`: read the latest result rather
218    /// than summing across results. Absent from CLIs before 2.1.239.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub subagent_stats: Option<SubagentStats>,
221
222    /// Structured-output payload returned by the model, when enabled.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub structured_output: Option<Value>,
225
226    /// Deferred tool-use termination payload.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub deferred_tool_use: Option<DeferredToolUse>,
229
230    /// Provenance of the message/run.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub origin: Option<super::message_types::MessageOrigin>,
233
234    /// Set by the self-hosted runner on the result it synthesizes when the
235    /// session process fails to start or exits abnormally. Absent on normal
236    /// results and on CLIs before 2.1.266.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub runner_exit: Option<RunnerExit>,
239
240    /// Why Claude Code refused to start. Set on the zeroed
241    /// `error_during_execution` result a stream-json run writes before
242    /// exiting on a known startup failure; `errors` carries the same text as
243    /// stderr. Failures that used to end with stderr alone write that result
244    /// only when the host sets `CLAUDE_CODE_STARTUP_FAILURE_RESULTS`. Absent
245    /// on every other result, on startup failures without a known cause, and
246    /// from CLIs before 2.1.274.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub startup_failure_reason: Option<StartupFailureReason>,
249}
250
251/// Why Claude Code refused to start, carried as
252/// [`ResultMessage::startup_failure_reason`] so a host can offer the fix
253/// instead of a retry (CLI 2.1.274+).
254#[derive(Debug, Clone, PartialEq, Eq, Hash)]
255pub enum StartupFailureReason {
256    /// Managed settings pin a first-party or Cloud gateway sign-in, and an
257    /// Anthropic API key or auth token is configured instead.
258    OrgPinApiKeyConflict,
259    /// The sign-in's organization could not be verified against the pin
260    /// (network, or a revoked token).
261    OrgVerifyFailed,
262    /// The sign-in belongs to an organization the pin does not allow.
263    OrgPinMismatch,
264    /// Managed policy settings could not be read, or the pin names no
265    /// organization.
266    ManagedSettingsInvalid,
267    /// Managed settings the organization requires could not be loaded.
268    RemoteSettingsRequiredUnavailable,
269    /// The Cloud gateway ended this sign-in.
270    GatewaySigninRequired,
271    /// The Cloud gateway refused managed settings for this account.
272    GatewayAccessDenied,
273    /// A proxy setting is not a complete URL.
274    ProxyInvalid,
275    /// The per-user temp directory is unsafe or could not be created.
276    TempDirUnusable,
277    /// The working directory was deleted, moved or cannot be read.
278    CwdUnavailable,
279    /// Windows has no shell tool: Git Bash is missing, and PowerShell is
280    /// missing or turned off by `CLAUDE_CODE_USE_POWERSHELL_TOOL`.
281    ShellToolMissing,
282    /// The conversation to resume or continue is running as a background
283    /// session.
284    SessionHeldByBackground,
285    /// The resume was refused because the session's worktree failed its
286    /// safety checks or the resume was launched from inside it; `errors`
287    /// says whether a re-run continues without the worktree.
288    WorktreeResumeRefused,
289    /// The session's worktree could not be verified right now; retrying may
290    /// succeed.
291    WorktreeUnverified,
292    /// This Claude Code version is below the minimum Anthropic requires.
293    CliVersionTooOld,
294    /// Bypass permissions mode was requested while running as root.
295    BypassRoot,
296    /// A cause not yet known to this version of the crate.
297    Unknown(String),
298}
299
300impl StartupFailureReason {
301    pub fn as_str(&self) -> &str {
302        match self {
303            Self::OrgPinApiKeyConflict => "org_pin_api_key_conflict",
304            Self::OrgVerifyFailed => "org_verify_failed",
305            Self::OrgPinMismatch => "org_pin_mismatch",
306            Self::ManagedSettingsInvalid => "managed_settings_invalid",
307            Self::RemoteSettingsRequiredUnavailable => "remote_settings_required_unavailable",
308            Self::GatewaySigninRequired => "gateway_signin_required",
309            Self::GatewayAccessDenied => "gateway_access_denied",
310            Self::ProxyInvalid => "proxy_invalid",
311            Self::TempDirUnusable => "temp_dir_unusable",
312            Self::CwdUnavailable => "cwd_unavailable",
313            Self::ShellToolMissing => "shell_tool_missing",
314            Self::SessionHeldByBackground => "session_held_by_background",
315            Self::WorktreeResumeRefused => "worktree_resume_refused",
316            Self::WorktreeUnverified => "worktree_unverified",
317            Self::CliVersionTooOld => "cli_version_too_old",
318            Self::BypassRoot => "bypass_root",
319            Self::Unknown(s) => s.as_str(),
320        }
321    }
322}
323
324impl fmt::Display for StartupFailureReason {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        f.write_str(self.as_str())
327    }
328}
329
330impl From<&str> for StartupFailureReason {
331    fn from(s: &str) -> Self {
332        match s {
333            "org_pin_api_key_conflict" => Self::OrgPinApiKeyConflict,
334            "org_verify_failed" => Self::OrgVerifyFailed,
335            "org_pin_mismatch" => Self::OrgPinMismatch,
336            "managed_settings_invalid" => Self::ManagedSettingsInvalid,
337            "remote_settings_required_unavailable" => Self::RemoteSettingsRequiredUnavailable,
338            "gateway_signin_required" => Self::GatewaySigninRequired,
339            "gateway_access_denied" => Self::GatewayAccessDenied,
340            "proxy_invalid" => Self::ProxyInvalid,
341            "temp_dir_unusable" => Self::TempDirUnusable,
342            "cwd_unavailable" => Self::CwdUnavailable,
343            "shell_tool_missing" => Self::ShellToolMissing,
344            "session_held_by_background" => Self::SessionHeldByBackground,
345            "worktree_resume_refused" => Self::WorktreeResumeRefused,
346            "worktree_unverified" => Self::WorktreeUnverified,
347            "cli_version_too_old" => Self::CliVersionTooOld,
348            "bypass_root" => Self::BypassRoot,
349            other => Self::Unknown(other.to_string()),
350        }
351    }
352}
353
354impl Serialize for StartupFailureReason {
355    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
356        serializer.serialize_str(self.as_str())
357    }
358}
359
360impl<'de> Deserialize<'de> for StartupFailureReason {
361    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
362        let s = String::deserialize(deserializer)?;
363        Ok(Self::from(s.as_str()))
364    }
365}
366
367/// How the self-hosted runner's session process terminated, carried on a
368/// synthesized failure [`ResultMessage`] as [`ResultMessage::runner_exit`]
369/// (CLI 2.1.266+).
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
371pub struct RunnerExit {
372    /// Which phase the process was in when it exited.
373    pub phase: RunnerExitPhase,
374    /// Process exit code. Absent (or wire `null`) when it exited on a signal
375    /// instead, or when the runner did not capture one.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub exit_code: Option<i64>,
378    /// Terminating signal name. Absent (or wire `null`) when it exited with a
379    /// code instead, or when the runner did not capture one.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub signal: Option<String>,
382}
383
384/// Which phase the runner's session process was in when it exited, in
385/// [`RunnerExit::phase`]. Open set — unrecognized values deserialize to
386/// [`RunnerExitPhase::Unknown`].
387#[derive(Debug, Clone, PartialEq, Eq, Hash)]
388pub enum RunnerExitPhase {
389    /// Failed while starting up.
390    Setup,
391    /// Failed once running.
392    Run,
393    /// A phase not yet known to this version of the crate.
394    Unknown(String),
395}
396
397impl RunnerExitPhase {
398    pub fn as_str(&self) -> &str {
399        match self {
400            Self::Setup => "setup",
401            Self::Run => "run",
402            Self::Unknown(s) => s.as_str(),
403        }
404    }
405}
406
407impl std::fmt::Display for RunnerExitPhase {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        f.write_str(self.as_str())
410    }
411}
412
413impl From<&str> for RunnerExitPhase {
414    fn from(s: &str) -> Self {
415        match s {
416            "setup" => Self::Setup,
417            "run" => Self::Run,
418            other => Self::Unknown(other.to_string()),
419        }
420    }
421}
422
423impl Serialize for RunnerExitPhase {
424    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
425        serializer.serialize_str(self.as_str())
426    }
427}
428
429impl<'de> Deserialize<'de> for RunnerExitPhase {
430    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
431        let s = String::deserialize(deserializer)?;
432        Ok(Self::from(s.as_str()))
433    }
434}
435
436/// Usage and cost for a single model within a session, as found in
437/// [`ResultMessage::model_usage`].
438///
439/// The `extra` field captures any keys the CLI adds that aren't modeled here,
440/// so new wire fields deserialize without error.
441#[derive(Debug, Clone, Default, Serialize, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct ModelUsageEntry {
444    #[serde(default)]
445    pub input_tokens: u64,
446    #[serde(default)]
447    pub output_tokens: u64,
448    #[serde(default)]
449    pub cache_read_input_tokens: u64,
450    #[serde(default)]
451    pub cache_creation_input_tokens: u64,
452    #[serde(default, rename = "costUSD")]
453    pub cost_usd: f64,
454    #[serde(default)]
455    pub web_search_requests: u32,
456    #[serde(default)]
457    pub context_window: u64,
458    #[serde(default)]
459    pub max_output_tokens: u64,
460    #[serde(flatten)]
461    pub extra: serde_json::Map<String, serde_json::Value>,
462}
463
464/// Tool use deferred by a terminal result.
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
466pub struct DeferredToolUse {
467    pub id: String,
468    pub name: String,
469    pub input: Value,
470}
471
472/// Running totals for subagents started through the Agent tool, carried on
473/// `result` frames (CLI 2.1.239+) as [`ResultMessage::subagent_stats`].
474///
475/// Cumulative for the session: a resumed session starts fresh, and a
476/// mid-session `/clear` zeroes it — though a background subagent that outlives
477/// the `/clear` still records its outcome, so `completed`, `failed`, and
478/// `killed` can then exceed `spawned`. Forked skills, workflows, teammates,
479/// and other internal agents are not counted.
480#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
481pub struct SubagentStats {
482    /// Subagents actually started; a refused or failed launch is not counted.
483    pub spawned: u64,
484    /// Spawns by the `run_in_background` value the model passed; all count as
485    /// `unset` while the parameter is not offered.
486    pub requested: SubagentSpawnRequests,
487    /// Spawns that started in the background after defaults and session
488    /// settings; the rest blocked the spawning tool call.
489    pub started_in_background: u64,
490    /// Spawns by agent type.
491    #[serde(default)]
492    pub by_type: std::collections::BTreeMap<String, u64>,
493    /// Deepest spawn: 1 = started by the main thread, 2 = by a depth-1
494    /// subagent.
495    pub max_depth: u64,
496    /// Spawns made from inside another subagent (depth > 1).
497    pub spawned_by_subagents: u64,
498    pub completed: u64,
499    pub failed: u64,
500    /// Subagents stopped before finishing, by who stopped them.
501    pub killed: SubagentKillCounts,
502    /// Agent tool calls turned down because a limit was reached (other
503    /// denials, such as an unknown agent type, are not counted).
504    pub refused: SubagentRefusalCounts,
505}
506
507/// Spawn counts by the `run_in_background` value the model passed, carried in
508/// [`SubagentStats::requested`].
509#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
510pub struct SubagentSpawnRequests {
511    pub background: u64,
512    pub foreground: u64,
513    pub unset: u64,
514}
515
516/// Subagents stopped before finishing, carried in [`SubagentStats::killed`].
517/// `parent` = by another agent through TaskStop; `system` = by Claude Code
518/// itself; `user` = any other stop.
519#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
520pub struct SubagentKillCounts {
521    pub parent: u64,
522    pub user: u64,
523    pub system: u64,
524}
525
526/// Agent tool calls refused at a limit, carried in [`SubagentStats::refused`].
527#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
528pub struct SubagentRefusalCounts {
529    pub depth_limit: u64,
530    pub concurrency_limit: u64,
531    pub budget: u64,
532}
533
534/// A record of a tool permission that was denied during the session.
535///
536/// This is included in `ResultMessage.permission_denials` to provide a summary
537/// of all permission denials that occurred.
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
539pub struct PermissionDenial {
540    /// The name of the tool that was blocked (e.g., "Bash", "Write")
541    pub tool_name: String,
542
543    /// The input that was passed to the tool
544    pub tool_input: Value,
545
546    /// The unique identifier for this tool use request
547    pub tool_use_id: String,
548}
549
550/// What the first stream POST of a turn was queued behind, carried as
551/// [`ResultMessage::first_stream_post_queued_behind`] (CLI 2.1.278+).
552/// Internal timing instrumentation; the CLI does not describe the values.
553#[derive(Debug, Clone, PartialEq, Eq, Hash)]
554pub enum StreamPostQueuedBehind {
555    /// `durable_post`
556    DurablePost,
557    /// `ephemeral_post`
558    EphemeralPost,
559    /// `retry_backoff`
560    RetryBackoff,
561    /// `hold`
562    Hold,
563    /// `none`
564    None,
565    /// A value not yet known to this version of the crate.
566    Unknown(String),
567}
568
569impl StreamPostQueuedBehind {
570    pub fn as_str(&self) -> &str {
571        match self {
572            Self::DurablePost => "durable_post",
573            Self::EphemeralPost => "ephemeral_post",
574            Self::RetryBackoff => "retry_backoff",
575            Self::Hold => "hold",
576            Self::None => "none",
577            Self::Unknown(s) => s.as_str(),
578        }
579    }
580}
581
582impl fmt::Display for StreamPostQueuedBehind {
583    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584        f.write_str(self.as_str())
585    }
586}
587
588impl From<&str> for StreamPostQueuedBehind {
589    fn from(s: &str) -> Self {
590        match s {
591            "durable_post" => Self::DurablePost,
592            "ephemeral_post" => Self::EphemeralPost,
593            "retry_backoff" => Self::RetryBackoff,
594            "hold" => Self::Hold,
595            "none" => Self::None,
596            other => Self::Unknown(other.to_string()),
597        }
598    }
599}
600
601impl Serialize for StreamPostQueuedBehind {
602    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
603        serializer.serialize_str(self.as_str())
604    }
605}
606
607impl<'de> Deserialize<'de> for StreamPostQueuedBehind {
608    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
609        let s = String::deserialize(deserializer)?;
610        Ok(Self::from(s.as_str()))
611    }
612}
613
614/// Why fast mode can't serve right now, carried on `result` frames and
615/// `system/init` (CLI 2.1.219+). Absent when nothing blocks fast mode.
616#[derive(Debug, Clone, PartialEq, Eq, Hash)]
617pub enum FastModeDisabledReason {
618    /// Free-tier account.
619    Free,
620    /// Disabled by user preference.
621    Preference,
622    /// Extra-usage purchases are disabled for the account.
623    ExtraUsageDisabled,
624    /// A network error prevented fast-mode eligibility from resolving.
625    NetworkError,
626    /// The CLI could not determine the reason.
627    UnknownReason,
628    /// Not a first-party API session (e.g. Bedrock/Vertex).
629    NotFirstParty,
630    /// Disabled via environment variable.
631    DisabledByEnv,
632    /// The active model does not support fast mode.
633    ModelNotAllowed,
634    /// SDK sessions must opt in to fast mode.
635    SdkOptInRequired,
636    /// Eligibility is still being determined.
637    Pending,
638    /// A reason not yet known to this version of the crate.
639    Unknown(String),
640}
641
642impl FastModeDisabledReason {
643    pub fn as_str(&self) -> &str {
644        match self {
645            Self::Free => "free",
646            Self::Preference => "preference",
647            Self::ExtraUsageDisabled => "extra_usage_disabled",
648            Self::NetworkError => "network_error",
649            Self::UnknownReason => "unknown",
650            Self::NotFirstParty => "not_first_party",
651            Self::DisabledByEnv => "disabled_by_env",
652            Self::ModelNotAllowed => "model_not_allowed",
653            Self::SdkOptInRequired => "sdk_opt_in_required",
654            Self::Pending => "pending",
655            Self::Unknown(s) => s.as_str(),
656        }
657    }
658}
659
660impl fmt::Display for FastModeDisabledReason {
661    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
662        f.write_str(self.as_str())
663    }
664}
665
666impl From<&str> for FastModeDisabledReason {
667    fn from(s: &str) -> Self {
668        match s {
669            "free" => Self::Free,
670            "preference" => Self::Preference,
671            "extra_usage_disabled" => Self::ExtraUsageDisabled,
672            "network_error" => Self::NetworkError,
673            "unknown" => Self::UnknownReason,
674            "not_first_party" => Self::NotFirstParty,
675            "disabled_by_env" => Self::DisabledByEnv,
676            "model_not_allowed" => Self::ModelNotAllowed,
677            "sdk_opt_in_required" => Self::SdkOptInRequired,
678            "pending" => Self::Pending,
679            other => Self::Unknown(other.to_string()),
680        }
681    }
682}
683
684impl Serialize for FastModeDisabledReason {
685    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
686        serializer.serialize_str(self.as_str())
687    }
688}
689
690impl<'de> Deserialize<'de> for FastModeDisabledReason {
691    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
692        let s = String::deserialize(deserializer)?;
693        Ok(Self::from(s.as_str()))
694    }
695}
696
697/// Result subtypes
698#[derive(Debug, Clone, PartialEq, Eq, Hash)]
699pub enum ResultSubtype {
700    Success,
701    ErrorMaxTurns,
702    ErrorDuringExecution,
703    ErrorMaxBudgetUsd,
704    ErrorMaxStructuredOutputRetries,
705    Unknown(String),
706}
707
708impl ResultSubtype {
709    pub fn as_str(&self) -> &str {
710        match self {
711            Self::Success => "success",
712            Self::ErrorMaxTurns => "error_max_turns",
713            Self::ErrorDuringExecution => "error_during_execution",
714            Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
715            Self::ErrorMaxStructuredOutputRetries => "error_max_structured_output_retries",
716            Self::Unknown(s) => s.as_str(),
717        }
718    }
719}
720
721impl fmt::Display for ResultSubtype {
722    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
723        f.write_str(self.as_str())
724    }
725}
726
727impl From<&str> for ResultSubtype {
728    fn from(s: &str) -> Self {
729        match s {
730            "success" => Self::Success,
731            "error_max_turns" => Self::ErrorMaxTurns,
732            "error_during_execution" => Self::ErrorDuringExecution,
733            "error_max_budget_usd" => Self::ErrorMaxBudgetUsd,
734            "error_max_structured_output_retries" => Self::ErrorMaxStructuredOutputRetries,
735            other => Self::Unknown(other.to_string()),
736        }
737    }
738}
739
740impl Serialize for ResultSubtype {
741    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
742        serializer.serialize_str(self.as_str())
743    }
744}
745
746impl<'de> Deserialize<'de> for ResultSubtype {
747    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
748        let s = String::deserialize(deserializer)?;
749        Ok(Self::from(s.as_str()))
750    }
751}
752
753/// Usage information for the request.
754///
755/// **These counters are accumulated roll-ups, not snapshots.** The CLI sums
756/// usage field-by-field across every API call ("iteration") in the turn's
757/// tool-use loop, so on a turn with N iterations each counter is the sum of
758/// all N. This is the right number for **cost**, and the wrong number for
759/// **context occupancy**: `cache_read_input_tokens` in particular re-counts
760/// the cached context on every iteration and can exceed the model's context
761/// window several times over on tool-heavy turns. To estimate context
762/// occupancy, use the *last* entry of [`iterations`](Self::iterations)
763/// instead (the CLI itself does exactly this).
764///
765/// Note: the `result` frame's usage covers the **main agent only** — the
766/// subagent (`Task` / sidechain) token rollup the CLI renders as
767/// `<subagent_tokens>` / `<agent_count>` is not carried here or anywhere
768/// else on the wire. Accumulate it from `Task` tool results with
769/// [`SubagentUsageRollup`](crate::SubagentUsageRollup).
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct UsageInfo {
772    /// Sum of fresh (uncached) input tokens across all iterations of the turn.
773    #[serde(default)]
774    pub input_tokens: u32,
775    /// Sum of cache-write input tokens across all iterations of the turn.
776    #[serde(default)]
777    pub cache_creation_input_tokens: u32,
778    /// Sum of cache-read input tokens across all iterations of the turn.
779    /// Re-counts the cached context every iteration — **not** a measure of
780    /// context occupancy; see the type-level docs.
781    #[serde(default)]
782    pub cache_read_input_tokens: u32,
783    /// Sum of output tokens across all iterations of the turn.
784    #[serde(default)]
785    pub output_tokens: u32,
786    #[serde(default)]
787    pub server_tool_use: ServerToolUse,
788    #[serde(default)]
789    pub service_tier: String,
790
791    /// Cache creation breakdown
792    #[serde(skip_serializing_if = "Option::is_none")]
793    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
794
795    /// Inference geography (e.g., "not_available")
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub inference_geo: Option<String>,
798
799    /// Per-iteration usage breakdown **within** this turn — one entry per
800    /// API call in the tool-use loop, in order. The last entry reflects the
801    /// final API call and is what the CLI reads for context-size estimates.
802    #[serde(default, skip_serializing_if = "Vec::is_empty")]
803    pub iterations: Vec<UsageIteration>,
804
805    /// Speed tier (e.g., "standard")
806    #[serde(skip_serializing_if = "Option::is_none")]
807    pub speed: Option<String>,
808
809    /// Output-token breakdown (CLI 2.1.232+): currently the thinking-token
810    /// share of `output_tokens`.
811    #[serde(skip_serializing_if = "Option::is_none")]
812    pub output_tokens_details: Option<OutputTokensDetails>,
813}
814
815/// Breakdown of a turn's output tokens, carried in
816/// [`UsageInfo::output_tokens_details`].
817#[derive(Debug, Clone, Serialize, Deserialize, Default)]
818pub struct OutputTokensDetails {
819    /// Output tokens spent on extended thinking.
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub thinking_tokens: Option<u64>,
822}
823
824/// Usage for a single API call ("iteration") within a turn's tool-use loop.
825///
826/// Carried in [`UsageInfo::iterations`]. The cache fields are optional on
827/// the wire: some frames carry only `input_tokens` + `output_tokens`
828/// (observed with `type: "turn"`), while others carry the full cache
829/// breakdown (observed with `type: "message"` in captured subagent
830/// sessions). The CLI computes its context estimate from
831/// `input_tokens + output_tokens` of the **last** iteration.
832#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct UsageIteration {
834    #[serde(default)]
835    pub input_tokens: u32,
836    #[serde(default)]
837    pub output_tokens: u32,
838    /// Cache-read input tokens for this iteration, when carried.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub cache_read_input_tokens: Option<u32>,
841    /// Cache-write input tokens for this iteration, when carried.
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub cache_creation_input_tokens: Option<u32>,
844    /// Cache-write breakdown by TTL, when carried.
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub cache_creation: Option<super::message_types::CacheCreationDetails>,
847    /// Iteration kind; `"turn"` and `"message"` observed on the wire.
848    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
849    pub kind: Option<String>,
850}
851
852/// Server tool usage information
853#[derive(Debug, Clone, Default, Serialize, Deserialize)]
854pub struct ServerToolUse {
855    #[serde(default)]
856    pub web_search_requests: u32,
857    /// Number of web fetch requests made
858    #[serde(default)]
859    pub web_fetch_requests: u32,
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use crate::io::ClaudeOutput;
866
867    #[test]
868    fn test_deserialize_result_message() {
869        let json = r#"{
870            "type": "result",
871            "subtype": "success",
872            "is_error": false,
873            "duration_ms": 100,
874            "duration_api_ms": 200,
875            "num_turns": 1,
876            "result": "Done",
877            "session_id": "123",
878            "total_cost_usd": 0.01,
879            "permission_denials": []
880        }"#;
881
882        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
883        assert!(!output.is_error());
884    }
885
886    #[test]
887    fn test_result_subtype_new_and_unknown_values_do_not_fail() {
888        let json = r#"{
889            "type": "result",
890            "subtype": "error_max_budget_usd",
891            "is_error": true,
892            "duration_ms": 100,
893            "duration_api_ms": 200,
894            "num_turns": 1,
895            "session_id": "123",
896            "total_cost_usd": 0.01
897        }"#;
898
899        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
900        let ClaudeOutput::Result(result) = output else {
901            panic!("Expected Result");
902        };
903        assert_eq!(result.subtype, ResultSubtype::ErrorMaxBudgetUsd);
904
905        let json = r#"{
906            "type": "result",
907            "subtype": "future_result_subtype",
908            "is_error": true,
909            "duration_ms": 100,
910            "duration_api_ms": 200,
911            "num_turns": 1,
912            "session_id": "123",
913            "total_cost_usd": 0.01
914        }"#;
915
916        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
917        let ClaudeOutput::Result(result) = output else {
918            panic!("Expected Result");
919        };
920        assert_eq!(
921            result.subtype,
922            ResultSubtype::Unknown("future_result_subtype".to_string())
923        );
924    }
925
926    #[test]
927    fn test_deserialize_result_with_permission_denials() {
928        let json = r#"{
929            "type": "result",
930            "subtype": "success",
931            "is_error": false,
932            "duration_ms": 100,
933            "duration_api_ms": 200,
934            "num_turns": 2,
935            "result": "Done",
936            "session_id": "123",
937            "total_cost_usd": 0.01,
938            "permission_denials": [
939                {
940                    "tool_name": "Bash",
941                    "tool_input": {"command": "rm -rf /", "description": "Delete everything"},
942                    "tool_use_id": "toolu_123"
943                }
944            ]
945        }"#;
946
947        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
948        if let ClaudeOutput::Result(result) = output {
949            assert_eq!(result.permission_denials.len(), 1);
950            assert_eq!(result.permission_denials[0].tool_name, "Bash");
951            assert_eq!(result.permission_denials[0].tool_use_id, "toolu_123");
952            assert_eq!(
953                result.permission_denials[0]
954                    .tool_input
955                    .get("command")
956                    .unwrap(),
957                "rm -rf /"
958            );
959        } else {
960            panic!("Expected Result");
961        }
962    }
963
964    #[test]
965    fn test_permission_denial_roundtrip() {
966        let denial = PermissionDenial {
967            tool_name: "Write".to_string(),
968            tool_input: serde_json::json!({"file_path": "/etc/passwd", "content": "bad"}),
969            tool_use_id: "toolu_456".to_string(),
970        };
971
972        let json = serde_json::to_string(&denial).unwrap();
973        assert!(json.contains("\"tool_name\":\"Write\""));
974        assert!(json.contains("\"tool_use_id\":\"toolu_456\""));
975        assert!(json.contains("/etc/passwd"));
976
977        let parsed: PermissionDenial = serde_json::from_str(&json).unwrap();
978        assert_eq!(parsed, denial);
979    }
980
981    #[test]
982    fn test_deserialize_result_message_with_errors() {
983        let json = r#"{
984            "type": "result",
985            "subtype": "error_during_execution",
986            "duration_ms": 0,
987            "duration_api_ms": 0,
988            "is_error": true,
989            "num_turns": 0,
990            "session_id": "27934753-425a-4182-892c-6b1c15050c3f",
991            "total_cost_usd": 0,
992            "errors": ["No conversation found with session ID: d56965c9-c855-4042-a8f5-f12bbb14d6f6"],
993            "permission_denials": []
994        }"#;
995
996        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
997        assert!(output.is_error());
998
999        if let ClaudeOutput::Result(res) = output {
1000            assert!(res.is_error);
1001            assert_eq!(res.errors.len(), 1);
1002            assert!(res.errors[0].contains("No conversation found"));
1003        } else {
1004            panic!("Expected Result message");
1005        }
1006    }
1007
1008    #[test]
1009    fn test_deserialize_result_message_errors_defaults_empty() {
1010        let json = r#"{
1011            "type": "result",
1012            "subtype": "success",
1013            "is_error": false,
1014            "duration_ms": 100,
1015            "duration_api_ms": 200,
1016            "num_turns": 1,
1017            "session_id": "123",
1018            "total_cost_usd": 0.01
1019        }"#;
1020
1021        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1022        if let ClaudeOutput::Result(res) = output {
1023            assert!(res.errors.is_empty());
1024        } else {
1025            panic!("Expected Result message");
1026        }
1027    }
1028
1029    #[test]
1030    fn test_result_message_errors_roundtrip() {
1031        let json = r#"{
1032            "type": "result",
1033            "subtype": "error_during_execution",
1034            "is_error": true,
1035            "duration_ms": 0,
1036            "duration_api_ms": 0,
1037            "num_turns": 0,
1038            "session_id": "test-session",
1039            "total_cost_usd": 0.0,
1040            "errors": ["Error 1", "Error 2"]
1041        }"#;
1042
1043        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1044        let reserialized = serde_json::to_string(&output).unwrap();
1045
1046        assert!(reserialized.contains("Error 1"));
1047        assert!(reserialized.contains("Error 2"));
1048    }
1049
1050    #[test]
1051    fn test_result_with_new_fields() {
1052        let json = r#"{
1053            "type": "result",
1054            "subtype": "success",
1055            "is_error": false,
1056            "duration_ms": 5000,
1057            "duration_api_ms": 4500,
1058            "num_turns": 1,
1059            "result": "Done",
1060            "session_id": "abc",
1061            "total_cost_usd": 0.06,
1062            "api_error_status": null,
1063            "stop_reason": "end_turn",
1064            "terminal_reason": "completed",
1065            "fast_mode_state": "off",
1066            "modelUsage": {
1067                "claude-opus-4-7[1m]": {
1068                    "inputTokens": 3817,
1069                    "outputTokens": 14,
1070                    "costUSD": 0.06
1071                }
1072            },
1073            "usage": {
1074                "input_tokens": 3817,
1075                "output_tokens": 14,
1076                "cache_creation_input_tokens": 3540,
1077                "cache_read_input_tokens": 0,
1078                "server_tool_use": {
1079                    "web_search_requests": 0,
1080                    "web_fetch_requests": 2
1081                },
1082                "service_tier": "standard",
1083                "inference_geo": "not_available",
1084                "speed": "standard",
1085                "iterations": [
1086                    {"input_tokens": 3817, "output_tokens": 14, "type": "turn"}
1087                ]
1088            }
1089        }"#;
1090
1091        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1092        if let ClaudeOutput::Result(res) = output {
1093            assert_eq!(res.stop_reason.as_deref(), Some("end_turn"));
1094            assert_eq!(res.terminal_reason.as_deref(), Some("completed"));
1095            assert_eq!(res.fast_mode_state.as_deref(), Some("off"));
1096            let model_usage = res.model_usage.as_ref().unwrap();
1097            let entry = model_usage
1098                .get("claude-opus-4-7[1m]")
1099                .expect("per-model entry present");
1100            assert_eq!(entry.input_tokens, 3817);
1101            assert_eq!(entry.output_tokens, 14);
1102            assert_eq!(entry.cost_usd, 0.06);
1103            assert!(res.api_error_status.is_none());
1104
1105            let usage = res.usage.unwrap();
1106            assert_eq!(usage.server_tool_use.web_fetch_requests, 2);
1107            assert_eq!(usage.inference_geo.as_deref(), Some("not_available"));
1108            assert_eq!(usage.speed.as_deref(), Some("standard"));
1109            assert_eq!(usage.iterations.len(), 1);
1110            assert_eq!(usage.iterations[0].input_tokens, 3817);
1111            assert_eq!(usage.iterations[0].output_tokens, 14);
1112            assert_eq!(usage.iterations[0].kind.as_deref(), Some("turn"));
1113        } else {
1114            panic!("Expected Result");
1115        }
1116    }
1117
1118    #[test]
1119    fn test_result_backwards_compatible_without_new_fields() {
1120        // Verify old-format messages still parse fine
1121        let json = r#"{
1122            "type": "result",
1123            "subtype": "success",
1124            "is_error": false,
1125            "duration_ms": 100,
1126            "duration_api_ms": 200,
1127            "num_turns": 1,
1128            "session_id": "abc",
1129            "total_cost_usd": 0.01
1130        }"#;
1131
1132        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
1133        if let ClaudeOutput::Result(res) = output {
1134            assert!(res.api_error_status.is_none());
1135            assert!(res.stop_reason.is_none());
1136            assert!(res.terminal_reason.is_none());
1137            assert!(res.fast_mode_state.is_none());
1138            assert!(res.model_usage.is_none());
1139        } else {
1140            panic!("Expected Result");
1141        }
1142    }
1143
1144    #[test]
1145    fn test_result_fast_mode_disabled_reason() {
1146        let json = r#"{
1147            "type":"result","subtype":"success","is_error":false,
1148            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1149            "session_id":"s1","total_cost_usd":0.01,
1150            "fast_mode_state":"off",
1151            "fast_mode_disabled_reason":"sdk_opt_in_required"
1152        }"#;
1153        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1154        let crate::ClaudeOutput::Result(res) = &output else {
1155            panic!("expected Result");
1156        };
1157        assert_eq!(
1158            res.fast_mode_disabled_reason,
1159            Some(FastModeDisabledReason::SdkOptInRequired)
1160        );
1161        assert!(serde_json::to_string(&output)
1162            .unwrap()
1163            .contains("\"fast_mode_disabled_reason\":\"sdk_opt_in_required\""));
1164
1165        // Unknown reasons survive decode and round-trip verbatim; the wire
1166        // literal "unknown" maps to the typed UnknownReason, not the fallback.
1167        assert_eq!(
1168            FastModeDisabledReason::from("unknown"),
1169            FastModeDisabledReason::UnknownReason
1170        );
1171        let novel = FastModeDisabledReason::from("solar_flare");
1172        assert_eq!(novel, FastModeDisabledReason::Unknown("solar_flare".into()));
1173        assert_eq!(novel.as_str(), "solar_flare");
1174    }
1175
1176    #[test]
1177    fn test_result_timing_and_user_message_uuid_fields() {
1178        let json = r#"{
1179            "type":"result","subtype":"success","is_error":false,
1180            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1181            "session_id":"s1","total_cost_usd":0.01,
1182            "request_sent_wall_ms":1753212345678.25,
1183            "user_message_uuid":"um-1"
1184        }"#;
1185        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1186        let crate::ClaudeOutput::Result(res) = &output else {
1187            panic!("expected Result");
1188        };
1189        assert_eq!(res.request_sent_wall_ms, Some(1753212345678.25));
1190        assert_eq!(res.user_message_uuid.as_deref(), Some("um-1"));
1191
1192        let reserialized = serde_json::to_string(&output).unwrap();
1193        assert!(reserialized.contains("\"user_message_uuid\":\"um-1\""));
1194    }
1195
1196    #[test]
1197    fn test_result_queued_turn_count_and_user_message_uuids() {
1198        // CLI 2.1.259 additive fields on the result frame.
1199        let json = r#"{
1200            "type":"result","subtype":"success","is_error":false,
1201            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1202            "session_id":"s1","total_cost_usd":0.01,
1203            "user_message_uuid":"um-2",
1204            "user_message_uuids":["um-1","um-2"],
1205            "queued_turn_count":3
1206        }"#;
1207        let output: crate::ClaudeOutput = serde_json::from_str(json).unwrap();
1208        let crate::ClaudeOutput::Result(res) = &output else {
1209            panic!("expected Result");
1210        };
1211        assert_eq!(res.user_message_uuids, vec!["um-1", "um-2"]);
1212        assert_eq!(res.queued_turn_count, Some(3));
1213
1214        let reserialized = serde_json::to_string(&output).unwrap();
1215        assert!(reserialized.contains("\"user_message_uuids\":[\"um-1\",\"um-2\"]"));
1216        assert!(reserialized.contains("\"queued_turn_count\":3"));
1217
1218        // Both fields are absent from older producers and must default cleanly.
1219        let old = r#"{
1220            "type":"result","subtype":"success","is_error":false,
1221            "duration_ms":100,"duration_api_ms":80,"num_turns":1,
1222            "session_id":"s1","total_cost_usd":0.01
1223        }"#;
1224        let output: crate::ClaudeOutput = serde_json::from_str(old).unwrap();
1225        let crate::ClaudeOutput::Result(res) = &output else {
1226            panic!("expected Result");
1227        };
1228        assert!(res.user_message_uuids.is_empty());
1229        assert_eq!(res.queued_turn_count, None);
1230        let reserialized = serde_json::to_string(&output).unwrap();
1231        assert!(!reserialized.contains("user_message_uuids"));
1232        assert!(!reserialized.contains("queued_turn_count"));
1233    }
1234}