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        StepConfig::Delay(_) => "delay",
443    };
444    tracing::Span::current().record("step.kind", kind);
445
446    let result = match config {
447        StepConfig::Shell(cfg) => {
448            let mut executor = ShellExecutor::new(cfg);
449            if let Some(sender) = log_sender {
450                executor = executor.with_log_sender(sender);
451            }
452            executor.execute(provider).await
453        }
454        StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
455        StepConfig::Agent(cfg) => {
456            let mut executor = AgentExecutor::new(cfg);
457            if let Some(sender) = log_sender {
458                executor = executor.with_log_sender(sender);
459            }
460            executor.execute(provider).await
461        }
462        StepConfig::Workflow(_) => Err(EngineError::StepConfig(
463            "workflow steps are executed by WorkflowContext, not the executor".to_string(),
464        )),
465        StepConfig::Approval(_) => Err(EngineError::StepConfig(
466            "approval steps are executed by WorkflowContext, not the executor".to_string(),
467        )),
468        StepConfig::Delay(_) => Err(EngineError::StepConfig(
469            "delay steps are executed by WorkflowContext, not the executor".to_string(),
470        )),
471    };
472
473    #[cfg(feature = "prometheus")]
474    {
475        use ironflow_core::metric_names::{
476            STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
477        };
478        use metrics::{counter, histogram};
479        let status = if result.is_ok() {
480            STATUS_SUCCESS
481        } else {
482            STATUS_ERROR
483        };
484        counter!(STEPS_TOTAL, "kind" => kind, "status" => status).increment(1);
485        if let Ok(ref output) = result {
486            histogram!(STEP_DURATION_SECONDS, "kind" => kind)
487                .record(output.duration_ms as f64 / 1000.0);
488        }
489    }
490
491    result
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use ironflow_core::provider::DebugMessage;
498    use serde_json::json;
499
500    #[test]
501    fn step_output_with_no_debug_messages_returns_none() {
502        let output = StepOutput {
503            output: json!({"result": "ok"}),
504            duration_ms: 100,
505            cost_usd: rust_decimal::Decimal::ZERO,
506            input_tokens: None,
507            output_tokens: None,
508            model: None,
509            debug_messages: None,
510        };
511
512        assert_eq!(output.debug_messages_json(), None);
513    }
514
515    #[test]
516    fn step_output_with_empty_debug_messages_returns_some_empty_array() {
517        let output = StepOutput {
518            output: json!({"result": "ok"}),
519            duration_ms: 100,
520            cost_usd: rust_decimal::Decimal::ZERO,
521            input_tokens: None,
522            output_tokens: None,
523            model: None,
524            debug_messages: Some(Vec::new()),
525        };
526
527        let json_val = output.debug_messages_json();
528        assert!(json_val.is_some());
529        let arr = json_val.unwrap();
530        assert!(arr.is_array());
531        assert_eq!(arr.as_array().unwrap().len(), 0);
532    }
533
534    #[test]
535    fn step_output_debug_messages_json_serializes_messages() {
536        let json_msgs = json!([
537            {
538                "text": "Hello",
539                "thinking": null,
540                "thinking_redacted": false,
541                "tool_calls": [],
542                "tool_results": [],
543                "stop_reason": "end_turn",
544                "input_tokens": 10,
545                "output_tokens": 20
546            },
547            {
548                "text": "Hi there",
549                "thinking": null,
550                "thinking_redacted": false,
551                "tool_calls": [],
552                "tool_results": [],
553                "stop_reason": "end_turn",
554                "input_tokens": 15,
555                "output_tokens": 25
556            }
557        ]);
558
559        let messages: Vec<DebugMessage> =
560            serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
561
562        let output = StepOutput {
563            output: json!({"result": "ok"}),
564            duration_ms: 100,
565            cost_usd: rust_decimal::Decimal::ZERO,
566            input_tokens: None,
567            output_tokens: None,
568            model: None,
569            debug_messages: Some(messages),
570        };
571
572        let json_val = output.debug_messages_json();
573        assert!(json_val.is_some());
574
575        let arr = json_val.unwrap();
576        assert!(arr.is_array());
577        let messages_array = arr.as_array().unwrap();
578        assert_eq!(messages_array.len(), 2);
579        assert_eq!(messages_array[0]["text"], "Hello");
580        assert_eq!(messages_array[1]["text"], "Hi there");
581    }
582
583    #[test]
584    fn step_output_contains_all_metrics() {
585        let output = StepOutput {
586            output: json!({"data": "test"}),
587            duration_ms: 5000,
588            cost_usd: rust_decimal::Decimal::new(123, 2),
589            input_tokens: Some(100),
590            output_tokens: Some(200),
591            model: Some("claude-sonnet".to_string()),
592            debug_messages: None,
593        };
594
595        assert_eq!(output.duration_ms, 5000);
596        assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
597        assert_eq!(output.input_tokens, Some(100));
598        assert_eq!(output.output_tokens, Some(200));
599        assert_eq!(output.model, Some("claude-sonnet".to_string()));
600    }
601
602    #[test]
603    fn step_output_default_tokens_and_model_are_none() {
604        let output = StepOutput {
605            output: json!({}),
606            duration_ms: 0,
607            cost_usd: rust_decimal::Decimal::ZERO,
608            input_tokens: None,
609            output_tokens: None,
610            model: None,
611            debug_messages: None,
612        };
613
614        assert!(output.input_tokens.is_none());
615        assert!(output.output_tokens.is_none());
616        assert!(output.model.is_none());
617    }
618
619    #[test]
620    fn parallel_step_result_contains_step_metadata() {
621        let step_id = uuid::Uuid::now_v7();
622        let output = StepOutput {
623            output: json!({"done": true}),
624            duration_ms: 1000,
625            cost_usd: rust_decimal::Decimal::ZERO,
626            input_tokens: None,
627            output_tokens: None,
628            model: None,
629            debug_messages: None,
630        };
631
632        let result = ParallelStepResult {
633            name: "build".to_string(),
634            output,
635            step_id,
636        };
637
638        assert_eq!(result.name, "build");
639        assert_eq!(result.step_id, step_id);
640        assert_eq!(result.output.duration_ms, 1000);
641    }
642
643    #[test]
644    fn step_output_serializes_complex_json_output() {
645        let complex_output = json!({
646            "status": "success",
647            "data": {
648                "items": [1, 2, 3],
649                "nested": {
650                    "key": "value"
651                }
652            }
653        });
654
655        let output = StepOutput {
656            output: complex_output.clone(),
657            duration_ms: 100,
658            cost_usd: rust_decimal::Decimal::ZERO,
659            input_tokens: None,
660            output_tokens: None,
661            model: None,
662            debug_messages: None,
663        };
664
665        assert_eq!(output.output, complex_output);
666        assert_eq!(output.output["status"], "success");
667        assert_eq!(output.output["data"]["items"][0], 1);
668        assert_eq!(output.output["data"]["nested"]["key"], "value");
669    }
670
671    #[test]
672    fn step_result_from_success_captures_all_fields() {
673        let trace_id = Uuid::nil();
674        let output = StepOutput {
675            output: json!({"stdout": "ok"}),
676            duration_ms: 1500,
677            cost_usd: Decimal::new(42, 2),
678            input_tokens: Some(100),
679            output_tokens: Some(200),
680            model: Some("claude-sonnet".to_string()),
681            debug_messages: None,
682        };
683
684        let result = StepResult::from_success(trace_id, "build", &output);
685
686        assert_eq!(result.trace_id, trace_id);
687        assert_eq!(result.name, "build");
688        assert_eq!(result.status, StepStatus::Completed);
689        assert_eq!(result.duration_ms, 1500);
690        assert_eq!(result.cost_usd, Decimal::new(42, 2));
691        assert_eq!(result.input_tokens, Some(100));
692        assert_eq!(result.output_tokens, Some(200));
693        assert!(result.error.is_none());
694        assert!(result.output_summary.is_some());
695        assert!(result.output_summary.unwrap().contains("stdout"));
696    }
697
698    #[test]
699    fn step_result_from_failure_captures_error() {
700        let trace_id = Uuid::nil();
701        let result =
702            StepResult::from_failure(trace_id, "deploy", "connection refused", 500, Decimal::ZERO);
703
704        assert_eq!(result.trace_id, trace_id);
705        assert_eq!(result.name, "deploy");
706        assert_eq!(result.status, StepStatus::Failed);
707        assert_eq!(result.duration_ms, 500);
708        assert_eq!(result.error, Some("connection refused".to_string()));
709        assert!(result.output_summary.is_none());
710    }
711
712    #[test]
713    fn step_result_output_summary_truncates_long_output() {
714        let long_value = json!({"data": "x".repeat(1000)});
715        let output = StepOutput {
716            output: long_value,
717            duration_ms: 0,
718            cost_usd: Decimal::ZERO,
719            input_tokens: None,
720            output_tokens: None,
721            model: None,
722            debug_messages: None,
723        };
724
725        let result = StepResult::from_success(Uuid::nil(), "test", &output);
726        let summary = result.output_summary.unwrap();
727        assert_eq!(summary.len(), 500);
728    }
729}
730
731#[cfg(test)]
732mod output_helper_tests {
733    use super::*;
734    use serde::Deserialize;
735    use serde_json::json;
736
737    fn output(value: Value) -> StepOutput {
738        StepOutput {
739            output: value,
740            duration_ms: 1,
741            cost_usd: Decimal::ZERO,
742            input_tokens: None,
743            output_tokens: None,
744            model: None,
745            debug_messages: None,
746        }
747    }
748
749    #[test]
750    fn shell_helpers_read_shell_fields() {
751        let out = output(json!({"stdout": "hi\n", "stderr": "warn", "exit_code": 0}));
752        assert_eq!(out.exit_code(), Some(0));
753        assert_eq!(out.stdout(), "hi\n");
754        assert_eq!(out.stderr(), "warn");
755        assert!(out.is_success());
756        assert_eq!(out.status(), None);
757        assert_eq!(out.body(), "");
758    }
759
760    #[test]
761    fn shell_non_zero_exit_is_not_success() {
762        let out = output(json!({"stdout": "", "stderr": "", "exit_code": 127}));
763        assert_eq!(out.exit_code(), Some(127));
764        assert!(!out.is_success());
765    }
766
767    #[test]
768    fn http_helpers_read_http_fields() {
769        let out = output(json!({"status": 200, "body": "{\"ok\":true}"}));
770        assert_eq!(out.status(), Some(200));
771        assert_eq!(out.body(), "{\"ok\":true}");
772        assert!(out.is_success());
773        assert_eq!(out.exit_code(), None);
774        assert_eq!(out.stdout(), "");
775    }
776
777    #[test]
778    fn http_error_status_is_not_success() {
779        assert!(!output(json!({"status": 500, "body": ""})).is_success());
780        assert!(!output(json!({"status": 199, "body": ""})).is_success());
781        assert!(output(json!({"status": 299, "body": ""})).is_success());
782    }
783
784    #[test]
785    fn status_out_of_u16_range_is_none() {
786        assert_eq!(output(json!({"status": 70000})).status(), None);
787        assert_eq!(output(json!({"status": "200"})).status(), None);
788    }
789
790    #[test]
791    fn agent_output_without_markers_is_not_success() {
792        let out = output(json!({"summary": "fine"}));
793        assert!(!out.is_success());
794        assert_eq!(out.exit_code(), None);
795        assert_eq!(out.stdout(), "");
796        assert_eq!(out.body(), "");
797    }
798
799    #[test]
800    fn json_deserializes_structured_output() {
801        #[derive(Deserialize, Debug, PartialEq)]
802        struct Review {
803            score: u8,
804            summary: String,
805        }
806        let out = output(json!({"score": 9, "summary": "good"}));
807        let review: Review = out.json().expect("matches schema");
808        assert_eq!(
809            review,
810            Review {
811                score: 9,
812                summary: "good".to_string()
813            }
814        );
815    }
816
817    #[test]
818    fn json_reports_mismatch_as_serialization_error() {
819        #[derive(Deserialize, Debug)]
820        struct Review {
821            #[allow(dead_code)]
822            score: u8,
823        }
824        let out = output(json!({"score": "nine"}));
825        let err = out.json::<Review>().expect_err("type mismatch");
826        assert!(matches!(err, EngineError::Serialization(_)));
827    }
828
829    #[test]
830    fn helpers_tolerate_non_object_output() {
831        let out = output(json!("plain text"));
832        assert_eq!(out.exit_code(), None);
833        assert_eq!(out.status(), None);
834        assert_eq!(out.stdout(), "");
835        assert!(!out.is_success());
836    }
837}