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