Skip to main content

agy_bridge/
types.rs

1//! Core types for the agent SDK bridge.
2//!
3//! This module defines the data structures that model an agent's execution
4//! trajectory: individual [`Step`](crate::types::Step)s,
5//! [`ToolCallInfo`](crate::types::ToolCallInfo) requests,
6//! [`ToolResult`](crate::types::ToolResult) responses, and
7//! [`UsageMetadata`](crate::types::UsageMetadata) for token accounting. All types derive
8//! `Serialize`/`Deserialize` for JSON interchange with the Python SDK.
9
10use std::{fmt, str::FromStr};
11
12use serde::{Deserialize, Serialize};
13use typed_builder::TypedBuilder;
14
15// =============================================================================
16// Step / ToolCall / ToolResult types (§1.6)
17// =============================================================================
18
19/// Define an SDK enum with `SCREAMING_SNAKE_CASE` serde rename and auto-generated
20/// `Display` and `FromStr` impls.
21///
22/// Each variant maps to a wire-format string. Unrecognized strings parse as `Err`
23/// via `FromStr` — they never panic.
24///
25/// # Syntax
26///
27/// ```text
28/// define_sdk_enum! {
29///     /// Doc comment for the enum.
30///     EnumName {
31///         Variant1 => "WIRE_STRING_1",
32///         Variant2 => "WIRE_STRING_2",
33///         #[default]
34///         Unknown => "UNKNOWN",
35///     }
36/// }
37/// ```
38macro_rules! define_sdk_enum {
39    (
40        $(#[$meta:meta])*
41        $name:ident {
42            $(
43                $(#[$vmeta:meta])*
44                $variant:ident => $wire:literal
45            ),+ $(,)?
46        }
47    ) => {
48        $(#[$meta])*
49        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50        #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
51        pub enum $name {
52            $(
53                $(#[$vmeta])*
54                $variant,
55            )+
56        }
57
58        impl fmt::Display for $name {
59            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60                let s = match self {
61                    $( Self::$variant => $wire, )+
62                };
63                f.write_str(s)
64            }
65        }
66
67        impl FromStr for $name {
68            type Err = String;
69
70            fn from_str(s: &str) -> Result<Self, Self::Err> {
71                match s {
72                    $( $wire => Ok(Self::$variant), )+
73                    other => Err(format!(concat!("Unrecognized ", stringify!($name), ": {:?}"), other)),
74                }
75            }
76        }
77    };
78}
79
80/// Like [`define_sdk_enum!`] but for enums where the serde wire format uses
81/// per-variant `#[serde(rename = "...")]` instead of `rename_all`.
82///
83/// This is needed for [`StepTarget`] whose SDK strings have a `TARGET_` prefix
84/// that doesn't match the `SCREAMING_SNAKE_CASE` of the enum name.
85macro_rules! define_sdk_enum_custom_serde {
86    (
87        $(#[$meta:meta])*
88        $name:ident {
89            $(
90                $(#[$vmeta:meta])*
91                $variant:ident => $wire:literal
92            ),+ $(,)?
93        }
94    ) => {
95        $(#[$meta])*
96        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
97        pub enum $name {
98            $(
99                $(#[$vmeta])*
100                #[serde(rename = $wire)]
101                $variant,
102            )+
103        }
104
105        impl fmt::Display for $name {
106            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107                let s = match self {
108                    $( Self::$variant => $wire, )+
109                };
110                f.write_str(s)
111            }
112        }
113
114        impl FromStr for $name {
115            type Err = String;
116
117            fn from_str(s: &str) -> Result<Self, Self::Err> {
118                match s {
119                    $( $wire => Ok(Self::$variant), )+
120                    other => Err(format!(concat!("Unrecognized ", stringify!($name), ": {:?}"), other)),
121                }
122            }
123        }
124    };
125}
126
127define_sdk_enum! {
128    /// The high-level type of a step in the agent trajectory.
129    StepType {
130        /// A textual response from the model.
131        TextResponse => "TEXT_RESPONSE",
132        /// A tool invocation requested by the model.
133        ToolCall => "TOOL_CALL",
134        /// A system-generated message (e.g. context injection).
135        SystemMessage => "SYSTEM_MESSAGE",
136        /// A context-window compaction event.
137        Compaction => "COMPACTION",
138        /// The agent has signaled task completion.
139        Finish => "FINISH",
140        /// Internal model thinking / reasoning step.
141        Thinking => "THINKING",
142        /// Unrecognized step type (forward-compatibility fallback).
143        #[default]
144        Unknown => "UNKNOWN",
145    }
146}
147
148define_sdk_enum! {
149    /// The source that generated a step.
150    StepSource {
151        /// Generated by the system runtime.
152        System => "SYSTEM",
153        /// Provided by the user.
154        User => "USER",
155        /// Generated by the model.
156        Model => "MODEL",
157        /// Unrecognized source (forward-compatibility fallback).
158        #[default]
159        Unknown => "UNKNOWN",
160    }
161}
162
163define_sdk_enum! {
164    /// The execution status of a step.
165    StepStatus {
166        /// Step is currently executing.
167        Active => "ACTIVE",
168        /// Step completed successfully.
169        Done => "DONE",
170        /// Step is blocked waiting for user input.
171        WaitingForUser => "WAITING_FOR_USER",
172        /// Step failed with an error.
173        Error => "ERROR",
174        /// Step was canceled before completion.
175        Canceled => "CANCELED",
176        /// Unrecognized status (forward-compatibility fallback).
177        #[default]
178        Unknown => "UNKNOWN",
179    }
180}
181
182define_sdk_enum_custom_serde! {
183    /// Target of a step interaction, mirroring the Python SDK's `StepTarget`.
184    ///
185    /// The Python SDK uses `TARGET_` prefixed strings (e.g. `TARGET_USER`).
186    /// Uses per-variant `#[serde(rename)]` because the SDK's wire format has a
187    /// `TARGET_` prefix that doesn't follow `SCREAMING_SNAKE_CASE` of the enum name.
188    StepTarget {
189        /// Step is directed at the model.
190        Model => "TARGET_MODEL",
191        /// Step is directed at the user.
192        User => "TARGET_USER",
193        /// Step is directed at the environment (tool execution).
194        Environment => "TARGET_ENVIRONMENT",
195        /// Target is unspecified.
196        Unspecified => "TARGET_UNSPECIFIED",
197        /// Unknown target (fallback).
198        #[default]
199        Unknown => "UNKNOWN",
200    }
201}
202
203/// A tool call from the model, mirroring the Python SDK's `ToolCall`.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ToolCallInfo {
206    /// Tool name — either a `BuiltinTools` string or a custom tool name.
207    pub name: String,
208    /// Arguments as a JSON value (typically an object/dict).
209    #[serde(default)]
210    pub args: serde_json::Value,
211    /// Optional unique identifier for the call.
212    #[serde(default)]
213    pub id: Option<String>,
214    /// Optional normalized filesystem path for file-related tools.
215    #[serde(default)]
216    pub canonical_path: Option<String>,
217}
218
219/// Result of a single tool execution, mirroring the Python SDK's `ToolResult`.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ToolResult {
222    /// The name of the tool that was executed.
223    pub name: String,
224    /// Optional identifier correlating this result with a `ToolCallInfo.id`.
225    #[serde(default)]
226    pub id: Option<String>,
227    /// The tool's return value (any JSON-serializable value).
228    #[serde(default)]
229    pub result: serde_json::Value,
230    /// An error message if execution failed, or `None` on success.
231    #[serde(default)]
232    pub error: Option<String>,
233}
234
235/// Token usage metadata from the model API, mirroring the SDK's `UsageMetadata`.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
237pub struct UsageMetadata {
238    /// Number of tokens in the prompt.
239    #[serde(default)]
240    pub prompt_token_count: Option<u64>,
241    /// Number of tokens from cached content (subset of prompt tokens).
242    #[serde(default)]
243    pub cached_content_token_count: Option<u64>,
244    /// Number of tokens in the generated candidates (excluding thinking).
245    #[serde(default)]
246    pub candidates_token_count: Option<u64>,
247    /// Number of tokens used for thinking/reasoning.
248    #[serde(default)]
249    pub thoughts_token_count: Option<u64>,
250    /// Sum of prompt + candidates + thinking tokens.
251    #[serde(default)]
252    pub total_token_count: Option<u64>,
253}
254
255/// The role of a message author in the conversation.
256#[non_exhaustive]
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "lowercase")]
259#[derive(Default)]
260pub enum MessageRole {
261    /// A user-authored message.
262    #[default]
263    User,
264    /// A model-generated message.
265    Model,
266    /// A system-level message.
267    System,
268    /// An unrecognized role — preserves the original string for forward
269    /// compatibility with new SDK roles.
270    #[serde(untagged)]
271    Unknown(String),
272}
273
274impl std::fmt::Display for MessageRole {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::User => f.write_str("user"),
278            Self::Model => f.write_str("model"),
279            Self::System => f.write_str("system"),
280            Self::Unknown(s) => f.write_str(s),
281        }
282    }
283}
284
285/// A single message in the conversation history, mirroring the Python SDK's
286/// `ConversationMessage`.
287///
288/// Each message has a [`MessageRole`] and textual `content`.
289#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
290pub struct ConversationMessage {
291    /// The role of the message author.
292    #[serde(default)]
293    pub role: MessageRole,
294    /// The textual content of the message.
295    #[serde(default)]
296    pub content: String,
297}
298
299/// A single step in the agent trajectory, mirroring the SDK's `Step`.
300///
301/// # Construction
302///
303/// `Step` is `#[non_exhaustive]`, so outside this crate it can only be built
304/// with the [`TypedBuilder`]. Every field defaults (matching [`Default`]), so
305/// callers set only the fields they care about:
306///
307/// ```
308/// use agy_bridge::Step;
309///
310/// let step = Step::builder()
311///     .id("traj:0")
312///     .content("Running command...")
313///     .build();
314/// assert_eq!(step.id, "traj:0");
315/// // Unset fields fall back to their defaults.
316/// assert_eq!(step.step_index, 0);
317/// assert!(step.tool_calls.is_empty());
318/// ```
319#[non_exhaustive]
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, TypedBuilder)]
321#[builder(field_defaults(default))]
322pub struct Step {
323    /// Unique string identifier for the step.
324    #[serde(default)]
325    #[builder(setter(into))]
326    pub id: String,
327    /// Integer index of the step in the trajectory.
328    #[serde(default)]
329    pub step_index: u32,
330    /// Identifier of the trajectory this step belongs to.
331    ///
332    /// The SDK's connection layer assigns one trajectory id per agent
333    /// trajectory. Compare with [`cascade_id`](Self::cascade_id) to tell a
334    /// primary-agent step (`trajectory_id == cascade_id`) apart from a subagent
335    /// step (`trajectory_id != cascade_id`); see [`Step::is_subagent_step`].
336    /// Empty when the harness did not report one.
337    #[serde(default)]
338    #[builder(setter(into))]
339    pub trajectory_id: String,
340    /// Identifier of the top-level cascade — i.e. the primary agent trajectory.
341    ///
342    /// Every step in a turn, primary and subagent alike, shares the same
343    /// `cascade_id`, so it identifies the parent trajectory. A step originates
344    /// from a subagent when its [`trajectory_id`](Self::trajectory_id) differs
345    /// from this value. Empty when the harness did not report one.
346    #[serde(default)]
347    #[builder(setter(into))]
348    pub cascade_id: String,
349    /// The high-level type of the step.
350    #[serde(default, rename = "type")]
351    pub step_type: StepType,
352    /// The source that generated the step.
353    #[serde(default)]
354    pub source: StepSource,
355    /// The target of the step interaction.
356    #[serde(default)]
357    pub target: StepTarget,
358    /// The status of the step.
359    #[serde(default)]
360    pub status: StepStatus,
361    /// The text content/output of the step.
362    #[serde(default)]
363    #[builder(setter(into))]
364    pub content: String,
365    /// Incremental text content added since the last update for this step.
366    #[serde(default)]
367    #[builder(setter(into))]
368    pub content_delta: String,
369    /// Full model reasoning/thinking text for planner responses.
370    #[serde(default)]
371    #[builder(setter(into))]
372    pub thinking: String,
373    /// Incremental thinking text added since the last update for this step.
374    #[serde(default)]
375    #[builder(setter(into))]
376    pub thinking_delta: String,
377    /// List of tool calls associated with the step.
378    #[serde(default)]
379    #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<ToolCallInfo>>| v.into_iter().map(Into::into).collect()))]
380    pub tool_calls: Vec<ToolCallInfo>,
381    /// Short error message if the step failed.
382    #[serde(default)]
383    #[builder(setter(into))]
384    pub error: String,
385    /// HTTP status code from the harness error, if any (e.g. 400, 429, 503).
386    ///
387    /// The SDK populates this from the harness's `error.http_code` field.
388    /// Used by error detection in `forward_step_to_writer` for logging.
389    #[serde(default)]
390    pub http_code: u16,
391    /// Whether this step is a completed model response directed at the user.
392    ///
393    /// Multiple steps per turn may have this flag set; consumers wanting only
394    /// the last response should iterate fully.
395    #[serde(default)]
396    #[builder(setter(strip_option))]
397    pub is_complete_response: Option<bool>,
398    /// Structured output payload extracted from the FINISH step.
399    ///
400    /// This is `serde_json::Value` because it contains user-defined schema data
401    /// whose shape is not known at compile time.
402    #[serde(default)]
403    #[builder(setter(strip_option))]
404    pub structured_output: Option<serde_json::Value>,
405    /// Token usage for this step's model invocation.
406    #[serde(default)]
407    #[builder(setter(strip_option))]
408    pub usage_metadata: Option<UsageMetadata>,
409}
410
411impl Step {
412    /// Whether this step originates from a subagent trajectory rather than the
413    /// primary agent's.
414    ///
415    /// Mirrors the SDK's own parent/subagent discrimination
416    /// (`cascade_id AND trajectory_id AND trajectory_id != cascade_id`): a step
417    /// is a subagent step only when it carries a known parent
418    /// [`cascade_id`](Self::cascade_id) *and* a
419    /// [`trajectory_id`](Self::trajectory_id) that differs from it. Steps
420    /// missing either id — e.g. from mocks or older harnesses — are treated as
421    /// primary.
422    #[must_use]
423    pub fn is_subagent_step(&self) -> bool {
424        !self.cascade_id.is_empty()
425            && !self.trajectory_id.is_empty()
426            && self.trajectory_id != self.cascade_id
427    }
428}
429
430macro_rules! impl_from_py_object {
431    ($($t:ty),+) => {
432        $(
433            impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for $t {
434                type Error = pyo3::PyErr;
435
436                fn extract(ob: pyo3::Borrowed<'a, 'py, pyo3::PyAny>) -> pyo3::PyResult<Self> {
437                    crate::runtime::py_scripts::warm_up_lazy_imports(ob.py());
438                    pythonize::depythonize(&*ob).map_err(|e| {
439                        pyo3::exceptions::PyValueError::new_err(format!(
440                            "Failed to deserialize {} from Python dict: {}",
441                            stringify!($t),
442                            e
443                        ))
444                    })
445                }
446            }
447        )+
448    };
449}
450
451impl_from_py_object!(
452    StepType,
453    StepSource,
454    StepStatus,
455    StepTarget,
456    ToolCallInfo,
457    ToolResult,
458    UsageMetadata,
459    MessageRole,
460    ConversationMessage,
461    Step
462);
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    // =========================================================================
469    // Step / ToolCall / ToolResult tests
470    // =========================================================================
471
472    #[test]
473    fn test_step_type_roundtrip() {
474        for (variant, expected_str) in [
475            (StepType::TextResponse, "\"TEXT_RESPONSE\""),
476            (StepType::ToolCall, "\"TOOL_CALL\""),
477            (StepType::SystemMessage, "\"SYSTEM_MESSAGE\""),
478            (StepType::Compaction, "\"COMPACTION\""),
479            (StepType::Finish, "\"FINISH\""),
480            (StepType::Thinking, "\"THINKING\""),
481            (StepType::Unknown, "\"UNKNOWN\""),
482        ] {
483            let json = serde_json::to_string(&variant).unwrap();
484            assert_eq!(
485                json, expected_str,
486                "StepType serialization mismatch for {variant:?}"
487            );
488            let parsed: StepType = serde_json::from_str(&json).unwrap();
489            assert_eq!(parsed, variant);
490        }
491    }
492
493    #[test]
494    fn test_step_type_parse() {
495        assert_eq!(
496            "TEXT_RESPONSE".parse::<StepType>().unwrap(),
497            StepType::TextResponse
498        );
499        assert_eq!("TOOL_CALL".parse::<StepType>().unwrap(), StepType::ToolCall);
500        assert_eq!(
501            "SYSTEM_MESSAGE".parse::<StepType>().unwrap(),
502            StepType::SystemMessage
503        );
504        assert_eq!(
505            "COMPACTION".parse::<StepType>().unwrap(),
506            StepType::Compaction
507        );
508        assert_eq!("FINISH".parse::<StepType>().unwrap(), StepType::Finish);
509        assert_eq!("THINKING".parse::<StepType>().unwrap(), StepType::Thinking);
510    }
511
512    #[test]
513    fn test_step_source_roundtrip() {
514        for (variant, expected_str) in [
515            (StepSource::System, "\"SYSTEM\""),
516            (StepSource::User, "\"USER\""),
517            (StepSource::Model, "\"MODEL\""),
518            (StepSource::Unknown, "\"UNKNOWN\""),
519        ] {
520            let json = serde_json::to_string(&variant).unwrap();
521            assert_eq!(json, expected_str);
522            let parsed: StepSource = serde_json::from_str(&json).unwrap();
523            assert_eq!(parsed, variant);
524        }
525    }
526
527    #[test]
528    fn test_step_source_parse() {
529        assert_eq!("SYSTEM".parse::<StepSource>().unwrap(), StepSource::System);
530        assert_eq!("USER".parse::<StepSource>().unwrap(), StepSource::User);
531        assert_eq!("MODEL".parse::<StepSource>().unwrap(), StepSource::Model);
532    }
533
534    #[test]
535    fn test_step_status_roundtrip() {
536        for (variant, expected_str) in [
537            (StepStatus::Active, "\"ACTIVE\""),
538            (StepStatus::Done, "\"DONE\""),
539            (StepStatus::WaitingForUser, "\"WAITING_FOR_USER\""),
540            (StepStatus::Error, "\"ERROR\""),
541            (StepStatus::Canceled, "\"CANCELED\""),
542            (StepStatus::Unknown, "\"UNKNOWN\""),
543        ] {
544            let json = serde_json::to_string(&variant).unwrap();
545            assert_eq!(json, expected_str);
546            let parsed: StepStatus = serde_json::from_str(&json).unwrap();
547            assert_eq!(parsed, variant);
548        }
549    }
550
551    #[test]
552    fn test_step_status_parse() {
553        assert_eq!("ACTIVE".parse::<StepStatus>().unwrap(), StepStatus::Active);
554        assert_eq!("DONE".parse::<StepStatus>().unwrap(), StepStatus::Done);
555        assert_eq!(
556            "WAITING_FOR_USER".parse::<StepStatus>().unwrap(),
557            StepStatus::WaitingForUser
558        );
559        assert_eq!("ERROR".parse::<StepStatus>().unwrap(), StepStatus::Error);
560        assert_eq!(
561            "CANCELED".parse::<StepStatus>().unwrap(),
562            StepStatus::Canceled
563        );
564    }
565
566    #[test]
567    fn test_step_type_parse_returns_err_for_unrecognized() {
568        assert!("NONEXISTENT".parse::<StepType>().is_err());
569    }
570
571    #[test]
572    fn test_step_source_parse_returns_err_for_unrecognized() {
573        assert!("???".parse::<StepSource>().is_err());
574    }
575
576    #[test]
577    fn test_step_status_parse_returns_err_for_unrecognized() {
578        assert!("nope".parse::<StepStatus>().is_err());
579    }
580
581    #[test]
582    fn test_tool_call_info_roundtrip() {
583        let tc = ToolCallInfo {
584            name: "view_file".to_string(),
585            args: serde_json::json!({"path": "/tmp/foo.rs", "line": 42}),
586            id: Some("call_123".to_string()),
587            canonical_path: Some("/tmp/foo.rs".to_string()),
588        };
589        let json = serde_json::to_string(&tc).unwrap();
590        let parsed: ToolCallInfo = serde_json::from_str(&json).unwrap();
591        assert_eq!(parsed, tc);
592    }
593
594    #[test]
595    fn test_tool_call_info_minimal() {
596        let json = r#"{"name":"custom_tool"}"#;
597        let parsed: ToolCallInfo = serde_json::from_str(json).unwrap();
598        assert_eq!(parsed.name, "custom_tool");
599        assert_eq!(parsed.args, serde_json::Value::Null);
600        assert!(parsed.id.is_none());
601        assert!(parsed.canonical_path.is_none());
602    }
603
604    #[test]
605    fn test_tool_result_roundtrip() {
606        let tr = ToolResult {
607            name: "run_command".to_string(),
608            id: Some("result_456".to_string()),
609            result: serde_json::json!({"output": "hello world"}),
610            error: None,
611        };
612        let json = serde_json::to_string(&tr).unwrap();
613        let parsed: ToolResult = serde_json::from_str(&json).unwrap();
614        assert_eq!(parsed, tr);
615    }
616
617    #[test]
618    fn test_tool_result_with_error() {
619        let tr = ToolResult {
620            name: "create_file".to_string(),
621            id: None,
622            result: serde_json::Value::Null,
623            error: Some("permission denied".to_string()),
624        };
625        let json = serde_json::to_string(&tr).unwrap();
626        let parsed: ToolResult = serde_json::from_str(&json).unwrap();
627        assert_eq!(parsed.error.as_deref(), Some("permission denied"));
628    }
629
630    #[test]
631    fn test_usage_metadata_roundtrip() {
632        let um = UsageMetadata {
633            prompt_token_count: Some(100),
634            cached_content_token_count: Some(20),
635            candidates_token_count: Some(50),
636            thoughts_token_count: Some(30),
637            total_token_count: Some(180),
638        };
639        let json = serde_json::to_string(&um).unwrap();
640        let parsed: UsageMetadata = serde_json::from_str(&json).unwrap();
641        assert_eq!(parsed, um);
642    }
643
644    #[test]
645    fn test_usage_metadata_defaults() {
646        let um: UsageMetadata = serde_json::from_str("{}").unwrap();
647        assert!(um.prompt_token_count.is_none());
648        assert!(um.total_token_count.is_none());
649    }
650
651    #[test]
652    fn test_step_full_roundtrip() {
653        let step = Step {
654            id: "traj:0".to_string(),
655            step_index: 3,
656            trajectory_id: "traj-sub".to_string(),
657            cascade_id: "traj-root".to_string(),
658            step_type: StepType::ToolCall,
659            source: StepSource::Model,
660            target: StepTarget::Environment,
661            status: StepStatus::Done,
662            content: "Running command...".to_string(),
663            content_delta: "Running".to_string(),
664            thinking: "I should run the command".to_string(),
665            thinking_delta: "I should".to_string(),
666            tool_calls: vec![ToolCallInfo {
667                name: "run_command".to_string(),
668                args: serde_json::json!({"command": "ls -la"}),
669                id: Some("call_1".to_string()),
670                canonical_path: None,
671            }],
672            error: String::new(),
673            http_code: 0,
674            is_complete_response: Some(false),
675            structured_output: None,
676            usage_metadata: Some(UsageMetadata {
677                prompt_token_count: Some(500),
678                cached_content_token_count: None,
679                candidates_token_count: Some(100),
680                thoughts_token_count: Some(50),
681                total_token_count: Some(650),
682            }),
683        };
684
685        let json = serde_json::to_string_pretty(&step).unwrap();
686        let parsed: Step = serde_json::from_str(&json).unwrap();
687        assert_eq!(parsed, step);
688        assert_eq!(parsed.tool_calls.len(), 1);
689        assert_eq!(parsed.tool_calls[0].name, "run_command");
690        // trajectory_id differs from cascade_id => subagent step.
691        assert!(parsed.is_subagent_step());
692    }
693
694    #[test]
695    fn test_step_minimal_deserialization() {
696        // Should deserialize with all defaults.
697        let json = r#"{"id":"s1"}"#;
698        let step: Step = serde_json::from_str(json).unwrap();
699        assert_eq!(step.id, "s1");
700        assert_eq!(step.step_index, 0);
701        assert_eq!(step.step_type, StepType::Unknown);
702        assert_eq!(step.source, StepSource::Unknown);
703        assert_eq!(step.target, StepTarget::Unknown);
704        assert_eq!(step.status, StepStatus::Unknown);
705        assert!(step.content.is_empty());
706        assert!(step.content_delta.is_empty());
707        assert!(step.thinking.is_empty());
708        assert!(step.thinking_delta.is_empty());
709        assert!(step.tool_calls.is_empty());
710        assert!(step.error.is_empty());
711        assert!(step.is_complete_response.is_none());
712        assert!(step.structured_output.is_none());
713        assert!(step.usage_metadata.is_none());
714        // No trajectory info => defaults to a primary (non-subagent) step.
715        assert!(step.trajectory_id.is_empty());
716        assert!(step.cascade_id.is_empty());
717        assert!(!step.is_subagent_step());
718    }
719
720    #[test]
721    fn is_subagent_step_matches_sdk_discrimination() {
722        // Primary step: trajectory_id == cascade_id (the SDK's parent rule).
723        let primary = Step {
724            trajectory_id: "traj-root".to_string(),
725            cascade_id: "traj-root".to_string(),
726            ..Step::default()
727        };
728        assert!(!primary.is_subagent_step());
729
730        // Subagent step: trajectory_id differs from the cascade (parent) id.
731        let subagent = Step {
732            trajectory_id: "traj-child".to_string(),
733            cascade_id: "traj-root".to_string(),
734            ..Step::default()
735        };
736        assert!(subagent.is_subagent_step());
737
738        // A trajectory_id with an empty cascade_id is treated as primary: the
739        // SDK only flags a subagent when the parent cascade id is known,
740        // matching the guard `cascade_id and trajectory_id and trajectory_id !=
741        // cascade_id`.
742        let no_cascade = Step {
743            trajectory_id: "traj-child".to_string(),
744            cascade_id: String::new(),
745            ..Step::default()
746        };
747        assert!(!no_cascade.is_subagent_step());
748    }
749
750    #[test]
751    fn step_deserializes_trajectory_and_cascade_ids() {
752        // The SDK's LocalConnectionStep emits these as top-level fields via
753        // model_dump(); verify they round-trip through our extraction path.
754        let json = r#"{"id":"s","trajectory_id":"t","cascade_id":"c"}"#;
755        let step: Step = serde_json::from_str(json).unwrap();
756        assert_eq!(step.trajectory_id, "t");
757        assert_eq!(step.cascade_id, "c");
758        assert!(step.is_subagent_step());
759    }
760
761    // =========================================================================
762    // Step with multiple tool calls
763    // =========================================================================
764
765    #[test]
766    fn step_with_multiple_tool_calls() {
767        let step = Step {
768            id: "multi-tc".to_string(),
769            step_index: 7,
770            trajectory_id: String::new(),
771            cascade_id: String::new(),
772            step_type: StepType::ToolCall,
773            source: StepSource::Model,
774            target: StepTarget::Environment,
775            status: StepStatus::Done,
776            content: String::new(),
777            content_delta: String::new(),
778            thinking: String::new(),
779            thinking_delta: String::new(),
780            tool_calls: vec![
781                ToolCallInfo {
782                    name: "view_file".to_string(),
783                    args: serde_json::json!({"path": "/a.rs"}),
784                    id: Some("tc1".to_string()),
785                    canonical_path: Some("/a.rs".to_string()),
786                },
787                ToolCallInfo {
788                    name: "run_command".to_string(),
789                    args: serde_json::json!({"command": "cargo test"}),
790                    id: Some("tc2".to_string()),
791                    canonical_path: None,
792                },
793            ],
794            error: String::new(),
795            http_code: 0,
796            is_complete_response: None,
797            structured_output: None,
798            usage_metadata: None,
799        };
800        let json = serde_json::to_string(&step).unwrap();
801        let parsed: Step = serde_json::from_str(&json).unwrap();
802        assert_eq!(parsed.tool_calls.len(), 2);
803        assert_eq!(parsed.tool_calls[0].name, "view_file");
804        assert_eq!(parsed.tool_calls[1].name, "run_command");
805        assert_eq!(
806            parsed.tool_calls[0].canonical_path.as_deref(),
807            Some("/a.rs")
808        );
809        assert!(parsed.tool_calls[1].canonical_path.is_none());
810    }
811
812    // =========================================================================
813    // ToolCallInfo / ToolResult edge cases
814    // =========================================================================
815
816    #[test]
817    fn tool_call_info_with_complex_args() {
818        let tc = ToolCallInfo {
819            name: "run_command".to_string(),
820            args: serde_json::json!({
821                "command": "cargo test",
822                "env": {"RUST_LOG": "debug"},
823                "timeout": 300,
824                "nested": [1, 2, {"deep": true}]
825            }),
826            id: None,
827            canonical_path: None,
828        };
829        let json = serde_json::to_string(&tc).unwrap();
830        let parsed: ToolCallInfo = serde_json::from_str(&json).unwrap();
831        assert_eq!(parsed.args["env"]["RUST_LOG"], "debug");
832        assert_eq!(parsed.args["nested"][2]["deep"], true);
833    }
834
835    #[test]
836    fn tool_result_with_complex_result() {
837        let tr = ToolResult {
838            name: "search_dir".to_string(),
839            id: Some("r1".to_string()),
840            result: serde_json::json!({
841                "matches": [
842                    {"file": "/src/main.rs", "line": 42},
843                    {"file": "/src/lib.rs", "line": 10},
844                ],
845                "total": 2
846            }),
847            error: None,
848        };
849        let json = serde_json::to_string(&tr).unwrap();
850        let parsed: ToolResult = serde_json::from_str(&json).unwrap();
851        assert_eq!(parsed.result["total"], 2);
852        assert_eq!(parsed.result["matches"][0]["line"], 42);
853    }
854
855    // =========================================================================
856    // UsageMetadata partial fields
857    // =========================================================================
858
859    #[test]
860    fn usage_metadata_partial_fields() {
861        let json = r#"{"prompt_token_count":100,"total_token_count":200}"#;
862        let um: UsageMetadata = serde_json::from_str(json).unwrap();
863        assert_eq!(um.prompt_token_count, Some(100));
864        assert!(um.cached_content_token_count.is_none());
865        assert!(um.candidates_token_count.is_none());
866        assert!(um.thoughts_token_count.is_none());
867        assert_eq!(um.total_token_count, Some(200));
868    }
869
870    // =========================================================================
871    // StepTarget tests
872    // =========================================================================
873
874    #[test]
875    fn test_step_target_roundtrip() {
876        for (variant, expected_str) in [
877            (StepTarget::User, "\"TARGET_USER\""),
878            (StepTarget::Environment, "\"TARGET_ENVIRONMENT\""),
879            (StepTarget::Unspecified, "\"TARGET_UNSPECIFIED\""),
880            (StepTarget::Unknown, "\"UNKNOWN\""),
881        ] {
882            let json = serde_json::to_string(&variant).unwrap();
883            assert_eq!(
884                json, expected_str,
885                "StepTarget serialization mismatch for {variant:?}"
886            );
887            let parsed: StepTarget = serde_json::from_str(&json).unwrap();
888            assert_eq!(parsed, variant);
889        }
890    }
891
892    #[test]
893    fn test_step_target_parse() {
894        assert_eq!(
895            "TARGET_MODEL".parse::<StepTarget>().unwrap(),
896            StepTarget::Model
897        );
898        assert_eq!(
899            "TARGET_USER".parse::<StepTarget>().unwrap(),
900            StepTarget::User
901        );
902        assert_eq!(
903            "TARGET_ENVIRONMENT".parse::<StepTarget>().unwrap(),
904            StepTarget::Environment
905        );
906        assert_eq!(
907            "TARGET_UNSPECIFIED".parse::<StepTarget>().unwrap(),
908            StepTarget::Unspecified
909        );
910        assert_eq!(
911            "UNKNOWN".parse::<StepTarget>().unwrap(),
912            StepTarget::Unknown
913        );
914    }
915
916    #[test]
917    fn test_step_target_parse_returns_err_for_unrecognized() {
918        assert!("INVALID_TARGET".parse::<StepTarget>().is_err());
919    }
920
921    // =========================================================================
922    // Display trait tests
923    // =========================================================================
924
925    #[test]
926    fn test_step_type_display() {
927        assert_eq!(StepType::TextResponse.to_string(), "TEXT_RESPONSE");
928        assert_eq!(StepType::ToolCall.to_string(), "TOOL_CALL");
929        assert_eq!(StepType::SystemMessage.to_string(), "SYSTEM_MESSAGE");
930        assert_eq!(StepType::Compaction.to_string(), "COMPACTION");
931        assert_eq!(StepType::Finish.to_string(), "FINISH");
932        assert_eq!(StepType::Thinking.to_string(), "THINKING");
933        assert_eq!(StepType::Unknown.to_string(), "UNKNOWN");
934    }
935
936    #[test]
937    fn test_step_source_display() {
938        assert_eq!(StepSource::System.to_string(), "SYSTEM");
939        assert_eq!(StepSource::User.to_string(), "USER");
940        assert_eq!(StepSource::Model.to_string(), "MODEL");
941        assert_eq!(StepSource::Unknown.to_string(), "UNKNOWN");
942    }
943
944    #[test]
945    fn test_step_status_display() {
946        assert_eq!(StepStatus::Active.to_string(), "ACTIVE");
947        assert_eq!(StepStatus::Done.to_string(), "DONE");
948        assert_eq!(StepStatus::WaitingForUser.to_string(), "WAITING_FOR_USER");
949        assert_eq!(StepStatus::Error.to_string(), "ERROR");
950        assert_eq!(StepStatus::Canceled.to_string(), "CANCELED");
951        assert_eq!(StepStatus::Unknown.to_string(), "UNKNOWN");
952    }
953
954    #[test]
955    fn test_step_target_display() {
956        assert_eq!(StepTarget::User.to_string(), "TARGET_USER");
957        assert_eq!(StepTarget::Environment.to_string(), "TARGET_ENVIRONMENT");
958        assert_eq!(StepTarget::Unspecified.to_string(), "TARGET_UNSPECIFIED");
959        assert_eq!(StepTarget::Unknown.to_string(), "UNKNOWN");
960    }
961
962    // =========================================================================
963    // Display → FromStr roundtrip tests
964    // =========================================================================
965
966    #[test]
967    fn test_step_type_display_from_str_roundtrip() {
968        for variant in [
969            StepType::TextResponse,
970            StepType::ToolCall,
971            StepType::SystemMessage,
972            StepType::Compaction,
973            StepType::Finish,
974            StepType::Thinking,
975            StepType::Unknown,
976        ] {
977            let s = variant.to_string();
978            let parsed: StepType = s.parse().unwrap();
979            assert_eq!(parsed, variant, "roundtrip failed for {variant:?}");
980        }
981    }
982
983    #[test]
984    fn test_step_source_display_from_str_roundtrip() {
985        for variant in [
986            StepSource::System,
987            StepSource::User,
988            StepSource::Model,
989            StepSource::Unknown,
990        ] {
991            let s = variant.to_string();
992            let parsed: StepSource = s.parse().unwrap();
993            assert_eq!(parsed, variant, "roundtrip failed for {variant:?}");
994        }
995    }
996
997    #[test]
998    fn test_step_status_display_from_str_roundtrip() {
999        for variant in [
1000            StepStatus::Active,
1001            StepStatus::Done,
1002            StepStatus::WaitingForUser,
1003            StepStatus::Error,
1004            StepStatus::Canceled,
1005            StepStatus::Unknown,
1006        ] {
1007            let s = variant.to_string();
1008            let parsed: StepStatus = s.parse().unwrap();
1009            assert_eq!(parsed, variant, "roundtrip failed for {variant:?}");
1010        }
1011    }
1012
1013    #[test]
1014    fn test_step_target_display_from_str_roundtrip() {
1015        for variant in [
1016            StepTarget::Model,
1017            StepTarget::User,
1018            StepTarget::Environment,
1019            StepTarget::Unspecified,
1020            StepTarget::Unknown,
1021        ] {
1022            let s = variant.to_string();
1023            let parsed: StepTarget = s.parse().unwrap();
1024            assert_eq!(parsed, variant, "roundtrip failed for {variant:?}");
1025        }
1026    }
1027
1028    // =========================================================================
1029    // FromStr with garbage input tests
1030    // =========================================================================
1031
1032    #[test]
1033    fn test_from_str_garbage_returns_err() {
1034        assert!("xyzzy".parse::<StepType>().is_err());
1035        assert!("xyzzy".parse::<StepSource>().is_err());
1036        assert!("xyzzy".parse::<StepStatus>().is_err());
1037        assert!("xyzzy".parse::<StepTarget>().is_err());
1038    }
1039
1040    #[test]
1041    fn test_from_str_empty_returns_err() {
1042        assert!("".parse::<StepType>().is_err());
1043        assert!("".parse::<StepSource>().is_err());
1044        assert!("".parse::<StepStatus>().is_err());
1045        assert!("".parse::<StepTarget>().is_err());
1046    }
1047
1048    #[test]
1049    fn test_from_str_case_sensitive() {
1050        // SDK strings are case-sensitive — lowercase should return Err.
1051        assert!("text_response".parse::<StepType>().is_err());
1052        assert!("system".parse::<StepSource>().is_err());
1053        assert!("active".parse::<StepStatus>().is_err());
1054        assert!("target_user".parse::<StepTarget>().is_err());
1055    }
1056
1057    // =========================================================================
1058    // MessageRole / ConversationMessage Tests
1059    // =========================================================================
1060
1061    #[test]
1062    fn test_message_role_roundtrip() {
1063        for (variant, expected_str) in [
1064            (MessageRole::User, "\"user\""),
1065            (MessageRole::Model, "\"model\""),
1066            (MessageRole::System, "\"system\""),
1067            (MessageRole::Unknown("custom".to_string()), "\"custom\""),
1068        ] {
1069            let json = serde_json::to_string(&variant).unwrap();
1070            assert_eq!(json, expected_str);
1071            let parsed: MessageRole = serde_json::from_str(&json).unwrap();
1072            assert_eq!(parsed, variant);
1073        }
1074    }
1075
1076    #[test]
1077    fn test_conversation_message_roundtrip() {
1078        let msg = ConversationMessage {
1079            role: MessageRole::Model,
1080            content: "Hello!".to_string(),
1081        };
1082        let json = serde_json::to_string(&msg).unwrap();
1083        let parsed: ConversationMessage = serde_json::from_str(&json).unwrap();
1084        assert_eq!(parsed, msg);
1085    }
1086
1087    // =========================================================================
1088    // PyO3 Extract Tests
1089    // =========================================================================
1090
1091    #[test]
1092    fn test_pyo3_extract_roundtrip() {
1093        use pyo3::{prelude::*, types::PyDictMethods};
1094        pyo3::Python::initialize();
1095        pyo3::Python::attach(|py| {
1096            let dict = pyo3::types::PyDict::new(py);
1097            dict.set_item("id", "step-1").unwrap();
1098            dict.set_item("step_index", 42).unwrap();
1099            dict.set_item("type", "TEXT_RESPONSE").unwrap();
1100            dict.set_item("source", "MODEL").unwrap();
1101            dict.set_item("target", "TARGET_USER").unwrap();
1102            dict.set_item("status", "DONE").unwrap();
1103
1104            let step: Step = dict.extract().expect("failed to extract Step");
1105            assert_eq!(step.id, "step-1");
1106            assert_eq!(step.step_index, 42);
1107            assert_eq!(step.step_type, StepType::TextResponse);
1108            assert_eq!(step.source, StepSource::Model);
1109            assert_eq!(step.target, StepTarget::User);
1110            assert_eq!(step.status, StepStatus::Done);
1111
1112            // Now test an enum
1113            let s = pyo3::types::PyString::new(py, "SYSTEM_MESSAGE");
1114            let st: StepType = s.extract().unwrap();
1115            assert_eq!(st, StepType::SystemMessage);
1116        });
1117    }
1118
1119    // =========================================================================
1120    // Step new fields tests
1121    // =========================================================================
1122
1123    #[test]
1124    fn step_with_deltas_and_thinking() {
1125        let step = Step {
1126            id: "s2".to_string(),
1127            step_index: 1,
1128            trajectory_id: String::new(),
1129            cascade_id: String::new(),
1130            step_type: StepType::TextResponse,
1131            source: StepSource::Model,
1132            target: StepTarget::User,
1133            status: StepStatus::Active,
1134            content: "Hello world".to_string(),
1135            content_delta: "world".to_string(),
1136            thinking: "The user said hi".to_string(),
1137            thinking_delta: "said hi".to_string(),
1138            tool_calls: vec![],
1139            error: String::new(),
1140            http_code: 0,
1141            is_complete_response: Some(true),
1142            structured_output: None,
1143            usage_metadata: None,
1144        };
1145        let json = serde_json::to_string(&step).unwrap();
1146        let parsed: Step = serde_json::from_str(&json).unwrap();
1147        assert_eq!(parsed.content_delta, "world");
1148        assert_eq!(parsed.thinking, "The user said hi");
1149        assert_eq!(parsed.thinking_delta, "said hi");
1150        assert_eq!(parsed.is_complete_response, Some(true));
1151        assert_eq!(parsed.target, StepTarget::User);
1152    }
1153
1154    #[test]
1155    fn step_with_structured_output() {
1156        let payload = serde_json::json!({"answer": 42, "valid": true});
1157        let step = Step {
1158            id: "finish-1".to_string(),
1159            step_index: 5,
1160            trajectory_id: String::new(),
1161            cascade_id: String::new(),
1162            step_type: StepType::Finish,
1163            source: StepSource::Model,
1164            target: StepTarget::User,
1165            status: StepStatus::Done,
1166            content: String::new(),
1167            content_delta: String::new(),
1168            thinking: String::new(),
1169            thinking_delta: String::new(),
1170            tool_calls: vec![],
1171            error: String::new(),
1172            http_code: 0,
1173            is_complete_response: Some(true),
1174            structured_output: Some(payload.clone()),
1175            usage_metadata: None,
1176        };
1177        let json = serde_json::to_string(&step).unwrap();
1178        let parsed: Step = serde_json::from_str(&json).unwrap();
1179        assert_eq!(parsed.structured_output, Some(payload));
1180        assert_eq!(parsed.step_type, StepType::Finish);
1181    }
1182}