Skip to main content

ironflow_engine/executor/
mod.rs

1//! Step executor — reconstructs operations from configs and runs them.
2//!
3//! Each step type (shell, HTTP, agent) has its own executor implementing
4//! the [`StepExecutor`] trait. The [`execute_step_config`] function dispatches
5//! to the appropriate executor based on the [`StepConfig`] variant.
6
7mod agent;
8mod decision;
9mod http;
10mod shell;
11
12use std::future::Future;
13use std::sync::Arc;
14
15use rust_decimal::Decimal;
16use serde::de::DeserializeOwned;
17use serde_json::{Value, from_value};
18use uuid::Uuid;
19
20use ironflow_core::provider::{AgentProvider, DebugMessage};
21use ironflow_store::entities::StepStatus;
22
23use crate::config::StepConfig;
24use crate::error::EngineError;
25use crate::log_sender::StepLogSender;
26
27pub use agent::AgentExecutor;
28pub use decision::{DecisionExecution, execute_decision};
29pub use http::HttpExecutor;
30pub use shell::ShellExecutor;
31
32/// Result of executing a single step.
33#[derive(Debug, Clone)]
34pub struct StepOutput {
35    /// Serialized output (stdout for shell, body for http, value for agent).
36    ///
37    /// For agent steps with a JSON schema, the value may not strictly conform
38    /// to the schema: Claude CLI can flatten wrapper objects with a single
39    /// array field, returning a bare array instead of `{"items": [...]}`.
40    /// Callers should handle both the expected wrapper and a bare value.
41    pub output: Value,
42    /// Wall-clock duration in milliseconds.
43    pub duration_ms: u64,
44    /// Cost in USD (agent steps only).
45    pub cost_usd: Decimal,
46    /// Input token count (agent steps only).
47    pub input_tokens: Option<u64>,
48    /// Output token count (agent steps only).
49    pub output_tokens: Option<u64>,
50    /// Model identifier used for agent steps (e.g. `"claude-sonnet-4-20250514"`).
51    pub model: Option<String>,
52    /// Conversation trace from verbose agent invocations.
53    pub debug_messages: Option<Vec<DebugMessage>>,
54}
55
56impl StepOutput {
57    /// Serialize debug messages to a JSON [`Value`] for store persistence.
58    ///
59    /// Returns `None` when verbose mode was off (no messages captured).
60    pub fn debug_messages_json(&self) -> Option<Value> {
61        self.debug_messages
62            .as_ref()
63            .and_then(|msgs| serde_json::to_value(msgs).ok())
64    }
65
66    /// Exit code of a shell step.
67    ///
68    /// Returns `None` for non-shell steps or when the field is absent.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use ironflow_engine::executor::StepOutput;
74    /// use rust_decimal::Decimal;
75    /// use serde_json::json;
76    ///
77    /// let output = StepOutput {
78    ///     output: json!({"stdout": "ok\n", "stderr": "", "exit_code": 0}),
79    ///     duration_ms: 3,
80    ///     cost_usd: Decimal::ZERO,
81    ///     input_tokens: None,
82    ///     output_tokens: None,
83    ///     model: None,
84    ///     debug_messages: None,
85    /// };
86    /// assert_eq!(output.exit_code(), Some(0));
87    /// ```
88    pub fn exit_code(&self) -> Option<i64> {
89        self.output.get("exit_code").and_then(Value::as_i64)
90    }
91
92    /// Standard output of a shell step, or an empty string for other kinds.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use ironflow_engine::executor::StepOutput;
98    /// use rust_decimal::Decimal;
99    /// use serde_json::json;
100    ///
101    /// let output = StepOutput {
102    ///     output: json!({"stdout": "42 tests passed\n", "stderr": "", "exit_code": 0}),
103    ///     duration_ms: 3,
104    ///     cost_usd: Decimal::ZERO,
105    ///     input_tokens: None,
106    ///     output_tokens: None,
107    ///     model: None,
108    ///     debug_messages: None,
109    /// };
110    /// assert!(output.stdout().contains("42 tests"));
111    /// ```
112    pub fn stdout(&self) -> &str {
113        self.output
114            .get("stdout")
115            .and_then(Value::as_str)
116            .unwrap_or_default()
117    }
118
119    /// Standard error of a shell step, or an empty string for other kinds.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use ironflow_engine::executor::StepOutput;
125    /// use rust_decimal::Decimal;
126    /// use serde_json::json;
127    ///
128    /// let output = StepOutput {
129    ///     output: json!({"stdout": "", "stderr": "warning: unused", "exit_code": 0}),
130    ///     duration_ms: 3,
131    ///     cost_usd: Decimal::ZERO,
132    ///     input_tokens: None,
133    ///     output_tokens: None,
134    ///     model: None,
135    ///     debug_messages: None,
136    /// };
137    /// assert_eq!(output.stderr(), "warning: unused");
138    /// ```
139    pub fn stderr(&self) -> &str {
140        self.output
141            .get("stderr")
142            .and_then(Value::as_str)
143            .unwrap_or_default()
144    }
145
146    /// HTTP status code of an HTTP step.
147    ///
148    /// Returns `None` for non-HTTP steps or when the field is absent.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use ironflow_engine::executor::StepOutput;
154    /// use rust_decimal::Decimal;
155    /// use serde_json::json;
156    ///
157    /// let output = StepOutput {
158    ///     output: json!({"status": 204, "body": ""}),
159    ///     duration_ms: 3,
160    ///     cost_usd: Decimal::ZERO,
161    ///     input_tokens: None,
162    ///     output_tokens: None,
163    ///     model: None,
164    ///     debug_messages: None,
165    /// };
166    /// assert_eq!(output.status(), Some(204));
167    /// ```
168    pub fn status(&self) -> Option<u16> {
169        self.output
170            .get("status")
171            .and_then(Value::as_u64)
172            .and_then(|s| u16::try_from(s).ok())
173    }
174
175    /// Response body of an HTTP step, or an empty string for other kinds.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use ironflow_engine::executor::StepOutput;
181    /// use rust_decimal::Decimal;
182    /// use serde_json::json;
183    ///
184    /// let output = StepOutput {
185    ///     output: json!({"status": 200, "body": "{\"ok\":true}"}),
186    ///     duration_ms: 3,
187    ///     cost_usd: Decimal::ZERO,
188    ///     input_tokens: None,
189    ///     output_tokens: None,
190    ///     model: None,
191    ///     debug_messages: None,
192    /// };
193    /// assert_eq!(output.body(), "{\"ok\":true}");
194    /// ```
195    pub fn body(&self) -> &str {
196        self.output
197            .get("body")
198            .and_then(Value::as_str)
199            .unwrap_or_default()
200    }
201
202    /// Whether the step succeeded from the point of view of its own kind.
203    ///
204    /// - Shell step: the exit code is `0`.
205    /// - HTTP step: the status is in the `2xx` range.
206    /// - Any other kind: `false`, since no success marker is recorded.
207    ///
208    /// Mostly useful after a step configured with `allow_failure()`, since a
209    /// failing step otherwise returns an error from the context method.
210    ///
211    /// # Examples
212    ///
213    /// ```
214    /// use ironflow_engine::executor::StepOutput;
215    /// use rust_decimal::Decimal;
216    /// use serde_json::json;
217    ///
218    /// let shell = StepOutput {
219    ///     output: json!({"stdout": "", "stderr": "", "exit_code": 1}),
220    ///     duration_ms: 3,
221    ///     cost_usd: Decimal::ZERO,
222    ///     input_tokens: None,
223    ///     output_tokens: None,
224    ///     model: None,
225    ///     debug_messages: None,
226    /// };
227    /// assert!(!shell.is_success());
228    ///
229    /// let http = StepOutput { output: json!({"status": 201, "body": ""}), ..shell.clone() };
230    /// assert!(http.is_success());
231    /// ```
232    pub fn is_success(&self) -> bool {
233        if let Some(code) = self.exit_code() {
234            return code == 0;
235        }
236        if let Some(status) = self.status() {
237            return (200..300).contains(&status);
238        }
239        false
240    }
241
242    /// Deserialize the step output into `T`.
243    ///
244    /// Intended for agent steps constrained by a JSON schema, and for custom
245    /// operations that return structured JSON.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`EngineError::Serialization`] when the output does not match `T`.
250    ///
251    /// # Examples
252    ///
253    /// ```
254    /// use ironflow_engine::executor::StepOutput;
255    /// use rust_decimal::Decimal;
256    /// use serde::Deserialize;
257    /// use serde_json::json;
258    ///
259    /// #[derive(Deserialize)]
260    /// struct Review {
261    ///     score: u8,
262    /// }
263    ///
264    /// let output = StepOutput {
265    ///     output: json!({"score": 8}),
266    ///     duration_ms: 3,
267    ///     cost_usd: Decimal::ZERO,
268    ///     input_tokens: None,
269    ///     output_tokens: None,
270    ///     model: None,
271    ///     debug_messages: None,
272    /// };
273    /// let review: Review = output.json()?;
274    /// assert_eq!(review.score, 8);
275    /// # Ok::<(), ironflow_engine::error::EngineError>(())
276    /// ```
277    pub fn json<T: DeserializeOwned>(&self) -> Result<T, EngineError> {
278        from_value(self.output.clone()).map_err(EngineError::Serialization)
279    }
280}
281
282/// Result of a single step within a [`parallel`](crate::context::WorkflowContext::parallel) batch.
283#[derive(Debug, Clone)]
284pub struct ParallelStepResult {
285    /// The step name (same as provided to `parallel()`).
286    pub name: String,
287    /// The step execution output.
288    pub output: StepOutput,
289    /// The step ID in the store (for dependency tracking).
290    pub step_id: Uuid,
291}
292
293/// Enriched result of a completed step, for post-execution inspection.
294///
295/// Collects the step's trace ID, status, metrics, and a truncated output
296/// summary into a single struct that the [`WorkflowContext`](crate::context::WorkflowContext)
297/// accumulates over the run.
298///
299/// # Examples
300///
301/// ```
302/// use ironflow_engine::executor::StepResult;
303/// use ironflow_store::entities::StepStatus;
304/// use rust_decimal::Decimal;
305/// use uuid::Uuid;
306///
307/// let result = StepResult {
308///     trace_id: Uuid::nil(),
309///     name: "build".to_string(),
310///     status: StepStatus::Completed,
311///     duration_ms: 1200,
312///     cost_usd: Decimal::ZERO,
313///     input_tokens: None,
314///     output_tokens: None,
315///     error: None,
316///     output_summary: Some("ok".to_string()),
317/// };
318/// assert_eq!(result.status, StepStatus::Completed);
319/// ```
320#[derive(Debug, Clone, serde::Serialize)]
321pub struct StepResult {
322    /// Deterministic trace ID for log correlation.
323    pub trace_id: Uuid,
324    /// Step name.
325    pub name: String,
326    /// Terminal status.
327    pub status: StepStatus,
328    /// Wall-clock duration in milliseconds.
329    pub duration_ms: u64,
330    /// Cost in USD.
331    pub cost_usd: Decimal,
332    /// Input token count (agent steps only).
333    pub input_tokens: Option<u64>,
334    /// Output token count (agent steps only).
335    pub output_tokens: Option<u64>,
336    /// Error message if the step failed.
337    pub error: Option<String>,
338    /// First 500 characters of the serialized output.
339    pub output_summary: Option<String>,
340}
341
342/// Maximum length of [`StepResult::output_summary`].
343const OUTPUT_SUMMARY_MAX_LEN: usize = 500;
344
345impl StepResult {
346    /// Build from a completed step's output.
347    pub fn from_success(trace_id: Uuid, name: &str, output: &StepOutput) -> Self {
348        Self {
349            trace_id,
350            name: name.to_string(),
351            status: StepStatus::Completed,
352            duration_ms: output.duration_ms,
353            cost_usd: output.cost_usd,
354            input_tokens: output.input_tokens,
355            output_tokens: output.output_tokens,
356            error: None,
357            output_summary: summarize_output(&output.output),
358        }
359    }
360
361    /// Build from a failed step.
362    pub fn from_failure(
363        trace_id: Uuid,
364        name: &str,
365        error: &str,
366        duration_ms: u64,
367        cost_usd: Decimal,
368    ) -> Self {
369        Self {
370            trace_id,
371            name: name.to_string(),
372            status: StepStatus::Failed,
373            duration_ms,
374            cost_usd,
375            input_tokens: None,
376            output_tokens: None,
377            error: Some(error.to_string()),
378            output_summary: None,
379        }
380    }
381}
382
383fn summarize_output(value: &Value) -> Option<String> {
384    let raw = value.to_string();
385    match raw.char_indices().nth(OUTPUT_SUMMARY_MAX_LEN) {
386        None => Some(raw),
387        Some((byte_idx, _)) => Some(raw[..byte_idx].to_string()),
388    }
389}
390
391/// Trait for step executors.
392///
393/// Each step type implements this trait to execute its specific operation
394/// and return a [`StepOutput`].
395pub trait StepExecutor: Send + Sync {
396    /// Execute the step and return structured output.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`EngineError`] if the operation fails.
401    fn execute(
402        &self,
403        provider: &Arc<dyn AgentProvider>,
404    ) -> impl Future<Output = Result<StepOutput, EngineError>> + Send;
405}
406
407/// Execute a [`StepConfig`] and return structured output.
408///
409/// When a [`StepLogSender`] is provided, executors that support streaming
410/// will emit log lines in real time (e.g. shell stdout/stderr).
411///
412/// # Errors
413///
414/// Returns [`EngineError::Operation`] if the operation fails.
415///
416/// # Examples
417///
418/// ```no_run
419/// use ironflow_engine::config::{StepConfig, ShellConfig};
420/// use ironflow_engine::executor::execute_step_config;
421/// use ironflow_core::provider::AgentProvider;
422/// use ironflow_core::providers::claude::ClaudeCodeProvider;
423/// use std::sync::Arc;
424///
425/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
426/// let provider: Arc<dyn AgentProvider> = Arc::new(ClaudeCodeProvider::new());
427/// let config = StepConfig::Shell(ShellConfig::new("echo hello"));
428/// let output = execute_step_config(&config, &provider, None).await?;
429/// # Ok(())
430/// # }
431/// ```
432#[tracing::instrument(name = "executor.execute_step", skip_all, fields(step.kind))]
433pub async fn execute_step_config(
434    config: &StepConfig,
435    provider: &Arc<dyn AgentProvider>,
436    log_sender: Option<StepLogSender>,
437) -> Result<StepOutput, EngineError> {
438    let kind = match config {
439        StepConfig::Shell(_) => "shell",
440        StepConfig::Http(_) => "http",
441        StepConfig::Agent(_) => "agent",
442        StepConfig::Workflow(_) => "workflow",
443        StepConfig::Approval(_) => "approval",
444        StepConfig::Decision(_) => "decision",
445        StepConfig::Delay(_) => "delay",
446    };
447    tracing::Span::current().record("step.kind", kind);
448
449    let result = match config {
450        StepConfig::Shell(cfg) => {
451            let mut executor = ShellExecutor::new(cfg);
452            if let Some(sender) = log_sender {
453                executor = executor.with_log_sender(sender);
454            }
455            executor.execute(provider).await
456        }
457        StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
458        StepConfig::Agent(cfg) => {
459            let mut executor = AgentExecutor::new(cfg);
460            if let Some(sender) = log_sender {
461                executor = executor.with_log_sender(sender);
462            }
463            executor.execute(provider).await
464        }
465        StepConfig::Workflow(_) => Err(EngineError::StepConfig(
466            "workflow steps are executed by WorkflowContext, not the executor".to_string(),
467        )),
468        StepConfig::Approval(_) => Err(EngineError::StepConfig(
469            "approval steps are executed by WorkflowContext, not the executor".to_string(),
470        )),
471        StepConfig::Decision(_) => Err(EngineError::StepConfig(
472            "decision steps are executed by WorkflowContext, not the executor".to_string(),
473        )),
474        StepConfig::Delay(_) => Err(EngineError::StepConfig(
475            "delay steps are executed by WorkflowContext, not the executor".to_string(),
476        )),
477    };
478
479    #[cfg(feature = "prometheus")]
480    {
481        use ironflow_core::metric_names::{
482            STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
483        };
484        use metrics::{counter, histogram};
485        let status = if result.is_ok() {
486            STATUS_SUCCESS
487        } else {
488            STATUS_ERROR
489        };
490        counter!(STEPS_TOTAL, "kind" => kind, "status" => status).increment(1);
491        if let Ok(ref output) = result {
492            histogram!(STEP_DURATION_SECONDS, "kind" => kind)
493                .record(output.duration_ms as f64 / 1000.0);
494        }
495    }
496
497    result
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use ironflow_core::provider::DebugMessage;
504    use serde_json::json;
505
506    #[test]
507    fn step_output_with_no_debug_messages_returns_none() {
508        let output = StepOutput {
509            output: json!({"result": "ok"}),
510            duration_ms: 100,
511            cost_usd: rust_decimal::Decimal::ZERO,
512            input_tokens: None,
513            output_tokens: None,
514            model: None,
515            debug_messages: None,
516        };
517
518        assert_eq!(output.debug_messages_json(), None);
519    }
520
521    #[test]
522    fn step_output_with_empty_debug_messages_returns_some_empty_array() {
523        let output = StepOutput {
524            output: json!({"result": "ok"}),
525            duration_ms: 100,
526            cost_usd: rust_decimal::Decimal::ZERO,
527            input_tokens: None,
528            output_tokens: None,
529            model: None,
530            debug_messages: Some(Vec::new()),
531        };
532
533        let json_val = output.debug_messages_json();
534        assert!(json_val.is_some());
535        let arr = json_val.unwrap();
536        assert!(arr.is_array());
537        assert_eq!(arr.as_array().unwrap().len(), 0);
538    }
539
540    #[test]
541    fn step_output_debug_messages_json_serializes_messages() {
542        let json_msgs = json!([
543            {
544                "text": "Hello",
545                "thinking": null,
546                "thinking_redacted": false,
547                "tool_calls": [],
548                "tool_results": [],
549                "stop_reason": "end_turn",
550                "input_tokens": 10,
551                "output_tokens": 20
552            },
553            {
554                "text": "Hi there",
555                "thinking": null,
556                "thinking_redacted": false,
557                "tool_calls": [],
558                "tool_results": [],
559                "stop_reason": "end_turn",
560                "input_tokens": 15,
561                "output_tokens": 25
562            }
563        ]);
564
565        let messages: Vec<DebugMessage> =
566            serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
567
568        let output = StepOutput {
569            output: json!({"result": "ok"}),
570            duration_ms: 100,
571            cost_usd: rust_decimal::Decimal::ZERO,
572            input_tokens: None,
573            output_tokens: None,
574            model: None,
575            debug_messages: Some(messages),
576        };
577
578        let json_val = output.debug_messages_json();
579        assert!(json_val.is_some());
580
581        let arr = json_val.unwrap();
582        assert!(arr.is_array());
583        let messages_array = arr.as_array().unwrap();
584        assert_eq!(messages_array.len(), 2);
585        assert_eq!(messages_array[0]["text"], "Hello");
586        assert_eq!(messages_array[1]["text"], "Hi there");
587    }
588
589    #[test]
590    fn step_output_contains_all_metrics() {
591        let output = StepOutput {
592            output: json!({"data": "test"}),
593            duration_ms: 5000,
594            cost_usd: rust_decimal::Decimal::new(123, 2),
595            input_tokens: Some(100),
596            output_tokens: Some(200),
597            model: Some("claude-sonnet".to_string()),
598            debug_messages: None,
599        };
600
601        assert_eq!(output.duration_ms, 5000);
602        assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
603        assert_eq!(output.input_tokens, Some(100));
604        assert_eq!(output.output_tokens, Some(200));
605        assert_eq!(output.model, Some("claude-sonnet".to_string()));
606    }
607
608    #[test]
609    fn step_output_default_tokens_and_model_are_none() {
610        let output = StepOutput {
611            output: json!({}),
612            duration_ms: 0,
613            cost_usd: rust_decimal::Decimal::ZERO,
614            input_tokens: None,
615            output_tokens: None,
616            model: None,
617            debug_messages: None,
618        };
619
620        assert!(output.input_tokens.is_none());
621        assert!(output.output_tokens.is_none());
622        assert!(output.model.is_none());
623    }
624
625    #[test]
626    fn parallel_step_result_contains_step_metadata() {
627        let step_id = uuid::Uuid::now_v7();
628        let output = StepOutput {
629            output: json!({"done": true}),
630            duration_ms: 1000,
631            cost_usd: rust_decimal::Decimal::ZERO,
632            input_tokens: None,
633            output_tokens: None,
634            model: None,
635            debug_messages: None,
636        };
637
638        let result = ParallelStepResult {
639            name: "build".to_string(),
640            output,
641            step_id,
642        };
643
644        assert_eq!(result.name, "build");
645        assert_eq!(result.step_id, step_id);
646        assert_eq!(result.output.duration_ms, 1000);
647    }
648
649    #[test]
650    fn step_output_serializes_complex_json_output() {
651        let complex_output = json!({
652            "status": "success",
653            "data": {
654                "items": [1, 2, 3],
655                "nested": {
656                    "key": "value"
657                }
658            }
659        });
660
661        let output = StepOutput {
662            output: complex_output.clone(),
663            duration_ms: 100,
664            cost_usd: rust_decimal::Decimal::ZERO,
665            input_tokens: None,
666            output_tokens: None,
667            model: None,
668            debug_messages: None,
669        };
670
671        assert_eq!(output.output, complex_output);
672        assert_eq!(output.output["status"], "success");
673        assert_eq!(output.output["data"]["items"][0], 1);
674        assert_eq!(output.output["data"]["nested"]["key"], "value");
675    }
676
677    #[test]
678    fn step_result_from_success_captures_all_fields() {
679        let trace_id = Uuid::nil();
680        let output = StepOutput {
681            output: json!({"stdout": "ok"}),
682            duration_ms: 1500,
683            cost_usd: Decimal::new(42, 2),
684            input_tokens: Some(100),
685            output_tokens: Some(200),
686            model: Some("claude-sonnet".to_string()),
687            debug_messages: None,
688        };
689
690        let result = StepResult::from_success(trace_id, "build", &output);
691
692        assert_eq!(result.trace_id, trace_id);
693        assert_eq!(result.name, "build");
694        assert_eq!(result.status, StepStatus::Completed);
695        assert_eq!(result.duration_ms, 1500);
696        assert_eq!(result.cost_usd, Decimal::new(42, 2));
697        assert_eq!(result.input_tokens, Some(100));
698        assert_eq!(result.output_tokens, Some(200));
699        assert!(result.error.is_none());
700        assert!(result.output_summary.is_some());
701        assert!(result.output_summary.unwrap().contains("stdout"));
702    }
703
704    #[test]
705    fn step_result_from_failure_captures_error() {
706        let trace_id = Uuid::nil();
707        let result =
708            StepResult::from_failure(trace_id, "deploy", "connection refused", 500, Decimal::ZERO);
709
710        assert_eq!(result.trace_id, trace_id);
711        assert_eq!(result.name, "deploy");
712        assert_eq!(result.status, StepStatus::Failed);
713        assert_eq!(result.duration_ms, 500);
714        assert_eq!(result.error, Some("connection refused".to_string()));
715        assert!(result.output_summary.is_none());
716    }
717
718    #[test]
719    fn step_result_output_summary_truncates_long_output() {
720        let long_value = json!({"data": "x".repeat(1000)});
721        let output = StepOutput {
722            output: long_value,
723            duration_ms: 0,
724            cost_usd: Decimal::ZERO,
725            input_tokens: None,
726            output_tokens: None,
727            model: None,
728            debug_messages: None,
729        };
730
731        let result = StepResult::from_success(Uuid::nil(), "test", &output);
732        let summary = result.output_summary.unwrap();
733        assert_eq!(summary.len(), 500);
734    }
735}
736
737#[cfg(test)]
738mod output_helper_tests {
739    use super::*;
740    use serde::Deserialize;
741    use serde_json::json;
742
743    fn output(value: Value) -> StepOutput {
744        StepOutput {
745            output: value,
746            duration_ms: 1,
747            cost_usd: Decimal::ZERO,
748            input_tokens: None,
749            output_tokens: None,
750            model: None,
751            debug_messages: None,
752        }
753    }
754
755    #[test]
756    fn shell_helpers_read_shell_fields() {
757        let out = output(json!({"stdout": "hi\n", "stderr": "warn", "exit_code": 0}));
758        assert_eq!(out.exit_code(), Some(0));
759        assert_eq!(out.stdout(), "hi\n");
760        assert_eq!(out.stderr(), "warn");
761        assert!(out.is_success());
762        assert_eq!(out.status(), None);
763        assert_eq!(out.body(), "");
764    }
765
766    #[test]
767    fn shell_non_zero_exit_is_not_success() {
768        let out = output(json!({"stdout": "", "stderr": "", "exit_code": 127}));
769        assert_eq!(out.exit_code(), Some(127));
770        assert!(!out.is_success());
771    }
772
773    #[test]
774    fn http_helpers_read_http_fields() {
775        let out = output(json!({"status": 200, "body": "{\"ok\":true}"}));
776        assert_eq!(out.status(), Some(200));
777        assert_eq!(out.body(), "{\"ok\":true}");
778        assert!(out.is_success());
779        assert_eq!(out.exit_code(), None);
780        assert_eq!(out.stdout(), "");
781    }
782
783    #[test]
784    fn http_error_status_is_not_success() {
785        assert!(!output(json!({"status": 500, "body": ""})).is_success());
786        assert!(!output(json!({"status": 199, "body": ""})).is_success());
787        assert!(output(json!({"status": 299, "body": ""})).is_success());
788    }
789
790    #[test]
791    fn status_out_of_u16_range_is_none() {
792        assert_eq!(output(json!({"status": 70000})).status(), None);
793        assert_eq!(output(json!({"status": "200"})).status(), None);
794    }
795
796    #[test]
797    fn agent_output_without_markers_is_not_success() {
798        let out = output(json!({"summary": "fine"}));
799        assert!(!out.is_success());
800        assert_eq!(out.exit_code(), None);
801        assert_eq!(out.stdout(), "");
802        assert_eq!(out.body(), "");
803    }
804
805    #[test]
806    fn json_deserializes_structured_output() {
807        #[derive(Deserialize, Debug, PartialEq)]
808        struct Review {
809            score: u8,
810            summary: String,
811        }
812        let out = output(json!({"score": 9, "summary": "good"}));
813        let review: Review = out.json().expect("matches schema");
814        assert_eq!(
815            review,
816            Review {
817                score: 9,
818                summary: "good".to_string()
819            }
820        );
821    }
822
823    #[test]
824    fn json_reports_mismatch_as_serialization_error() {
825        #[derive(Deserialize, Debug)]
826        struct Review {
827            #[allow(dead_code)]
828            score: u8,
829        }
830        let out = output(json!({"score": "nine"}));
831        let err = out.json::<Review>().expect_err("type mismatch");
832        assert!(matches!(err, EngineError::Serialization(_)));
833    }
834
835    #[test]
836    fn helpers_tolerate_non_object_output() {
837        let out = output(json!("plain text"));
838        assert_eq!(out.exit_code(), None);
839        assert_eq!(out.status(), None);
840        assert_eq!(out.stdout(), "");
841        assert!(!out.is_success());
842    }
843}