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//!
7//! Every executor declares the [`StepKind`] it handles via
8//! [`StepExecutor::kind`], and [`execute_step_config`] derives the span and
9//! metric label from that kind. A new step type therefore only has to declare
10//! its kind instead of extending a match here.
11
12mod agent;
13mod decision;
14mod http;
15mod interceptor;
16mod shell;
17mod step_artifacts;
18mod stored;
19mod workflow_output;
20
21use std::borrow::Cow;
22use std::future::Future;
23use std::sync::Arc;
24
25use rust_decimal::Decimal;
26use serde::de::DeserializeOwned;
27use serde_json::{Value, from_value};
28use tracing::Span;
29use uuid::Uuid;
30
31use ironflow_core::provider::{AgentProvider, DebugMessage};
32use ironflow_store::entities::{StepKind, StepStatus};
33
34use crate::config::{AgentText, StepConfig};
35use crate::error::EngineError;
36use crate::log_sender::StepLogSender;
37
38pub use agent::AgentExecutor;
39pub use decision::{DecisionExecution, execute_decision};
40pub use http::HttpExecutor;
41pub use interceptor::{ApprovalOutcome, HumanInputOutcome, SignalOutcome, StepInterceptor};
42pub use shell::ShellExecutor;
43pub use step_artifacts::StepArtifacts;
44pub use workflow_output::{ConcurrencyConflict, SubWorkflowOutcome, SubWorkflowOutput};
45
46pub(crate) use workflow_output::RecordedWorkflowStep;
47
48/// Key under which a tolerated (`allow_failure`) step failure stores its message.
49pub(crate) const ERROR_KEY: &str = "error";
50
51/// Result of executing a single step.
52#[derive(Debug, Clone)]
53pub struct StepOutput {
54    /// Serialized output (stdout for shell, body for http, value for agent).
55    ///
56    /// For agent steps with a JSON schema, the value may not strictly conform
57    /// to the schema: Claude CLI can flatten wrapper objects with a single
58    /// array field, returning a bare array instead of `{"items": [...]}`.
59    /// Callers should handle both the expected wrapper and a bare value.
60    pub output: Value,
61    /// Wall-clock duration in milliseconds.
62    pub duration_ms: u64,
63    /// Cost in USD (agent steps only).
64    pub cost_usd: Decimal,
65    /// Uncached input token count (agent steps only).
66    pub input_tokens: Option<u64>,
67    /// Input tokens served from the prompt cache (agent steps only).
68    pub cache_read_input_tokens: Option<u64>,
69    /// Input tokens written to the prompt cache (agent steps only).
70    pub cache_creation_input_tokens: Option<u64>,
71    /// Output token count (agent steps only).
72    pub output_tokens: Option<u64>,
73    /// Model identifier used for agent steps (e.g. `"claude-sonnet-4-20250514"`).
74    pub model: Option<String>,
75    /// Conversation trace from verbose agent invocations.
76    pub debug_messages: Option<Vec<DebugMessage>>,
77    /// Artifacts the step can hand out through [`artifact`](Self::artifact).
78    /// Filled by the workflow context; executors leave the default.
79    pub artifacts: StepArtifacts,
80    /// Provider Account the agent step ran under, `None` for other steps and
81    /// agent steps that used the worker environment.
82    pub account_id: Option<Uuid>,
83    /// Persistent environment the agent step ran in (the claim name to pass to
84    /// [`Agent::resume_environment`](ironflow_core::operations::agent::Agent::resume_environment)),
85    /// `None` for other steps and providers without persistent environments.
86    pub environment_id: Option<String>,
87}
88
89impl StepOutput {
90    /// Total tokens consumed by the step: uncached input, cache reads, cache
91    /// writes and output. Missing counts are treated as 0 and the sum saturates.
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
97    /// use rust_decimal::Decimal;
98    /// use serde_json::json;
99    ///
100    /// let output = StepOutput {
101    ///     output: json!("ok"),
102    ///     duration_ms: 10,
103    ///     cost_usd: Decimal::ZERO,
104    ///     input_tokens: Some(100),
105    ///     cache_read_input_tokens: Some(5000),
106    ///     cache_creation_input_tokens: Some(200),
107    ///     output_tokens: Some(50),
108    ///     model: None,
109    ///     debug_messages: None,
110    ///     artifacts: StepArtifacts::default(),
111    ///     account_id: None,
112    ///     environment_id: None,
113    /// };
114    /// assert_eq!(output.total_tokens(), 5350);
115    /// ```
116    pub fn total_tokens(&self) -> u64 {
117        [
118            self.input_tokens,
119            self.cache_read_input_tokens,
120            self.cache_creation_input_tokens,
121            self.output_tokens,
122        ]
123        .into_iter()
124        .map(|t| t.unwrap_or(0))
125        .fold(0u64, u64::saturating_add)
126    }
127
128    /// Serialize debug messages to a JSON [`Value`] for store persistence.
129    ///
130    /// Returns `None` when verbose mode was off (no messages captured).
131    pub fn debug_messages_json(&self) -> Option<Value> {
132        self.debug_messages
133            .as_ref()
134            .and_then(|msgs| serde_json::to_value(msgs).ok())
135    }
136
137    /// Exit code of a shell step.
138    ///
139    /// Returns `None` for non-shell steps or when the field is absent.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
145    /// use rust_decimal::Decimal;
146    /// use serde_json::json;
147    ///
148    /// let output = StepOutput {
149    ///     output: json!({"stdout": "ok\n", "stderr": "", "exit_code": 0}),
150    ///     duration_ms: 3,
151    ///     cost_usd: Decimal::ZERO,
152    ///     input_tokens: None,
153    ///     cache_read_input_tokens: None,
154    ///     cache_creation_input_tokens: None,
155    ///     output_tokens: None,
156    ///     model: None,
157    ///     debug_messages: None,
158    ///     artifacts: StepArtifacts::default(),
159    ///     account_id: None,
160    ///     environment_id: None,
161    /// };
162    /// assert_eq!(output.exit_code(), Some(0));
163    /// ```
164    pub fn exit_code(&self) -> Option<i64> {
165        self.output.get("exit_code").and_then(Value::as_i64)
166    }
167
168    /// Standard output of a shell step, or an empty string for other kinds.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
174    /// use rust_decimal::Decimal;
175    /// use serde_json::json;
176    ///
177    /// let output = StepOutput {
178    ///     output: json!({"stdout": "42 tests passed\n", "stderr": "", "exit_code": 0}),
179    ///     duration_ms: 3,
180    ///     cost_usd: Decimal::ZERO,
181    ///     input_tokens: None,
182    ///     cache_read_input_tokens: None,
183    ///     cache_creation_input_tokens: None,
184    ///     output_tokens: None,
185    ///     model: None,
186    ///     debug_messages: None,
187    ///     artifacts: StepArtifacts::default(),
188    ///     account_id: None,
189    ///     environment_id: None,
190    /// };
191    /// assert!(output.stdout().contains("42 tests"));
192    /// ```
193    pub fn stdout(&self) -> &str {
194        self.output
195            .get("stdout")
196            .and_then(Value::as_str)
197            .unwrap_or_default()
198    }
199
200    /// Standard error of a shell step, or an empty string for other kinds.
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
206    /// use rust_decimal::Decimal;
207    /// use serde_json::json;
208    ///
209    /// let output = StepOutput {
210    ///     output: json!({"stdout": "", "stderr": "warning: unused", "exit_code": 0}),
211    ///     duration_ms: 3,
212    ///     cost_usd: Decimal::ZERO,
213    ///     input_tokens: None,
214    ///     cache_read_input_tokens: None,
215    ///     cache_creation_input_tokens: None,
216    ///     output_tokens: None,
217    ///     model: None,
218    ///     debug_messages: None,
219    ///     artifacts: StepArtifacts::default(),
220    ///     account_id: None,
221    ///     environment_id: None,
222    /// };
223    /// assert_eq!(output.stderr(), "warning: unused");
224    /// ```
225    pub fn stderr(&self) -> &str {
226        self.output
227            .get("stderr")
228            .and_then(Value::as_str)
229            .unwrap_or_default()
230    }
231
232    /// HTTP status code of an HTTP step.
233    ///
234    /// Returns `None` for non-HTTP steps or when the field is absent.
235    ///
236    /// # Examples
237    ///
238    /// ```
239    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
240    /// use rust_decimal::Decimal;
241    /// use serde_json::json;
242    ///
243    /// let output = StepOutput {
244    ///     output: json!({"status": 204, "body": ""}),
245    ///     duration_ms: 3,
246    ///     cost_usd: Decimal::ZERO,
247    ///     input_tokens: None,
248    ///     cache_read_input_tokens: None,
249    ///     cache_creation_input_tokens: None,
250    ///     output_tokens: None,
251    ///     model: None,
252    ///     debug_messages: None,
253    ///     artifacts: StepArtifacts::default(),
254    ///     account_id: None,
255    ///     environment_id: None,
256    /// };
257    /// assert_eq!(output.status(), Some(204));
258    /// ```
259    pub fn status(&self) -> Option<u16> {
260        self.output
261            .get("status")
262            .and_then(Value::as_u64)
263            .and_then(|s| u16::try_from(s).ok())
264    }
265
266    /// Response body of an HTTP step, or an empty string for other kinds.
267    ///
268    /// # Examples
269    ///
270    /// ```
271    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
272    /// use rust_decimal::Decimal;
273    /// use serde_json::json;
274    ///
275    /// let output = StepOutput {
276    ///     output: json!({"status": 200, "body": "{\"ok\":true}"}),
277    ///     duration_ms: 3,
278    ///     cost_usd: Decimal::ZERO,
279    ///     input_tokens: None,
280    ///     cache_read_input_tokens: None,
281    ///     cache_creation_input_tokens: None,
282    ///     output_tokens: None,
283    ///     model: None,
284    ///     debug_messages: None,
285    ///     artifacts: StepArtifacts::default(),
286    ///     account_id: None,
287    ///     environment_id: None,
288    /// };
289    /// assert_eq!(output.body(), "{\"ok\":true}");
290    /// ```
291    pub fn body(&self) -> &str {
292        self.output
293            .get("body")
294            .and_then(Value::as_str)
295            .unwrap_or_default()
296    }
297
298    /// Text answer of an agent step without structured output, or an empty
299    /// [`AgentText`] for other kinds.
300    ///
301    /// The agent wrote this text, possibly repeating instructions planted in
302    /// what it read: it is an [`AgentText`], not a `&str`, so it reaches the
303    /// next prompt only inside an `untrusted` block,
304    /// `Prompt::new().text("..").untrusted(&out.text())`. Read it with
305    /// [`AgentText::raw`] for a log, an artifact, a shell argument or a
306    /// comment. Its `{:?}` prints `AgentText(<N bytes>)`, not the text.
307    ///
308    /// # Examples
309    ///
310    /// ```
311    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
312    /// use rust_decimal::Decimal;
313    /// use serde_json::json;
314    ///
315    /// let answer = StepOutput {
316    ///     output: json!("Looks good."),
317    ///     duration_ms: 3,
318    ///     cost_usd: Decimal::ZERO,
319    ///     input_tokens: None,
320    ///     cache_read_input_tokens: None,
321    ///     cache_creation_input_tokens: None,
322    ///     output_tokens: None,
323    ///     model: None,
324    ///     debug_messages: None,
325    ///     artifacts: StepArtifacts::default(),
326    ///     account_id: None,
327    ///     environment_id: None,
328    /// };
329    /// assert_eq!(answer.text(), "Looks good.");
330    /// assert_eq!(answer.text().raw(), "Looks good.");
331    /// assert_eq!(StepOutput { output: json!({"stdout": "x"}), ..answer }.text(), "");
332    /// ```
333    ///
334    /// In the prompt of the next step:
335    ///
336    /// ```no_run
337    /// use ironflow_engine::config::{AgentStepConfig, Prompt};
338    /// use ironflow_engine::context::WorkflowContext;
339    /// use ironflow_engine::error::EngineError;
340    ///
341    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
342    /// let summary = ctx.agent("summarize", AgentStepConfig::new("Summarize the page")).await?;
343    /// let prompt = Prompt::new()
344    ///     .text("Act on this summary:")
345    ///     .untrusted(&summary.text());
346    /// ctx.agent("act", AgentStepConfig::new(&prompt.to_string())).await?;
347    /// # Ok(())
348    /// # }
349    /// ```
350    ///
351    /// It is no prompt by itself:
352    ///
353    /// ```compile_fail,E0308
354    /// use ironflow_engine::config::AgentStepConfig;
355    /// use ironflow_engine::context::WorkflowContext;
356    /// use ironflow_engine::error::EngineError;
357    ///
358    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
359    /// let out = ctx.agent("summarize", AgentStepConfig::new("Summarize the page")).await?;
360    /// ctx.agent("act", AgentStepConfig::new(&out.text())).await?;
361    /// # Ok(())
362    /// # }
363    /// ```
364    pub fn text(&self) -> AgentText {
365        AgentText::from(self.output.as_str().unwrap_or_default())
366    }
367
368    /// Error message of a step tolerated with `allow_failure()` that failed.
369    ///
370    /// An `allow_failure` step that failed (timeout, spawn failure, non-zero
371    /// exit of a shell step without `exit_code_as_output()`, failed input
372    /// preparation) has the output `{"error": "<message>"}`. This accessor
373    /// returns that message. It returns `None` for a successful step, and for
374    /// a shell step configured with `exit_code_as_output()`: the exit code is
375    /// data there, read it with [`exit_code`](Self::exit_code).
376    ///
377    /// It complements [`stdout`](Self::stdout), [`stderr`](Self::stderr),
378    /// [`exit_code`](Self::exit_code), [`status`](Self::status),
379    /// [`body`](Self::body) and [`text`](Self::text). It reads the `"error"`
380    /// key of the output, so it is meant for `allow_failure` steps: an agent or
381    /// operation whose own JSON has an `"error"` key is read the same way.
382    ///
383    /// # Examples
384    ///
385    /// ```
386    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
387    /// use rust_decimal::Decimal;
388    /// use serde_json::json;
389    ///
390    /// let failed = StepOutput {
391    ///     output: json!({"error": "timed out"}),
392    ///     duration_ms: 3,
393    ///     cost_usd: Decimal::ZERO,
394    ///     input_tokens: None,
395    ///     cache_read_input_tokens: None,
396    ///     cache_creation_input_tokens: None,
397    ///     output_tokens: None,
398    ///     model: None,
399    ///     debug_messages: None,
400    ///     artifacts: StepArtifacts::default(),
401    ///     account_id: None,
402    ///     environment_id: None,
403    /// };
404    /// assert_eq!(failed.error(), Some("timed out"));
405    /// assert!(!failed.is_success());
406    ///
407    /// let ok = StepOutput { output: json!({"stdout": "", "stderr": "", "exit_code": 0}), ..failed };
408    /// assert_eq!(ok.error(), None);
409    /// ```
410    ///
411    /// ```no_run
412    /// use ironflow_engine::config::ShellConfig;
413    /// use ironflow_engine::context::WorkflowContext;
414    /// use ironflow_engine::error::EngineError;
415    ///
416    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
417    /// let out = ctx
418    ///     .shell("fetch", ShellConfig::new("sleep 30").timeout_secs(1).allow_failure())
419    ///     .await?;
420    /// assert!(out.error().is_some());
421    /// assert!(!out.is_success());
422    /// # Ok(())
423    /// # }
424    /// ```
425    pub fn error(&self) -> Option<&str> {
426        self.output.get(ERROR_KEY).and_then(Value::as_str)
427    }
428
429    /// Whether the step succeeded from the point of view of its own kind.
430    ///
431    /// - Shell step: the exit code is `0`.
432    /// - HTTP step: the status is in the `2xx` range.
433    /// - Any other kind: `false`, since no success marker is recorded.
434    ///
435    /// Mostly useful after a step configured with `allow_failure()`, since a
436    /// failing step otherwise returns an error from the context method.
437    ///
438    /// # Examples
439    ///
440    /// ```
441    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
442    /// use rust_decimal::Decimal;
443    /// use serde_json::json;
444    ///
445    /// let shell = StepOutput {
446    ///     output: json!({"stdout": "", "stderr": "", "exit_code": 1}),
447    ///     duration_ms: 3,
448    ///     cost_usd: Decimal::ZERO,
449    ///     input_tokens: None,
450    ///     cache_read_input_tokens: None,
451    ///     cache_creation_input_tokens: None,
452    ///     output_tokens: None,
453    ///     model: None,
454    ///     debug_messages: None,
455    ///     artifacts: StepArtifacts::default(),
456    ///     account_id: None,
457    ///     environment_id: None,
458    /// };
459    /// assert!(!shell.is_success());
460    ///
461    /// let http = StepOutput { output: json!({"status": 201, "body": ""}), ..shell.clone() };
462    /// assert!(http.is_success());
463    /// ```
464    pub fn is_success(&self) -> bool {
465        if let Some(code) = self.exit_code() {
466            return code == 0;
467        }
468        if let Some(status) = self.status() {
469            return (200..300).contains(&status);
470        }
471        false
472    }
473
474    /// Deserialize the step output into `T`.
475    ///
476    /// Intended for custom operations that return structured JSON. For an
477    /// agent step, prefer `.output::<T>()` on the config: `ctx.agent` then
478    /// returns the `T`, which must implement
479    /// [`AgentAnswer`](crate::config::AgentAnswer) so its free-text fields
480    /// are [`AgentText`]. `json` checks nothing of the kind: `json::<String>()`
481    /// or a `String` field reads agent text unmarked, and so does
482    /// [`output`](Self::output) or the `{:?}` of a `StepOutput`, which prints
483    /// that `Value`. Never put such text in a prompt.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`EngineError::Serialization`] when the output does not match `T`.
488    ///
489    /// # Examples
490    ///
491    /// ```
492    /// use ironflow_engine::executor::{StepArtifacts, StepOutput};
493    /// use rust_decimal::Decimal;
494    /// use serde::Deserialize;
495    /// use serde_json::json;
496    ///
497    /// #[derive(Deserialize)]
498    /// struct Review {
499    ///     score: u8,
500    /// }
501    ///
502    /// let output = StepOutput {
503    ///     output: json!({"score": 8}),
504    ///     duration_ms: 3,
505    ///     cost_usd: Decimal::ZERO,
506    ///     input_tokens: None,
507    ///     cache_read_input_tokens: None,
508    ///     cache_creation_input_tokens: None,
509    ///     output_tokens: None,
510    ///     model: None,
511    ///     debug_messages: None,
512    ///     artifacts: StepArtifacts::default(),
513    ///     account_id: None,
514    ///     environment_id: None,
515    /// };
516    /// let review: Review = output.json()?;
517    /// assert_eq!(review.score, 8);
518    /// # Ok::<(), ironflow_engine::error::EngineError>(())
519    /// ```
520    pub fn json<T: DeserializeOwned>(&self) -> Result<T, EngineError> {
521        from_value(self.output.clone()).map_err(EngineError::Serialization)
522    }
523}
524
525/// Result of a single step within a [`parallel`](crate::context::WorkflowContext::parallel) batch.
526#[derive(Debug, Clone)]
527pub struct ParallelStepResult {
528    /// The step name (same as provided to `parallel()`).
529    pub name: String,
530    /// The step execution output.
531    pub output: StepOutput,
532    /// The step ID in the store (for dependency tracking).
533    pub step_id: Uuid,
534}
535
536/// Enriched result of a completed step, for post-execution inspection.
537///
538/// Collects the step's trace ID, status, metrics, and a truncated output
539/// summary into a single struct that the [`WorkflowContext`](crate::context::WorkflowContext)
540/// accumulates over the run.
541///
542/// # Examples
543///
544/// ```
545/// use ironflow_engine::executor::StepResult;
546/// use ironflow_store::entities::StepStatus;
547/// use rust_decimal::Decimal;
548/// use uuid::Uuid;
549///
550/// let result = StepResult {
551///     trace_id: Uuid::nil(),
552///     name: "build".to_string(),
553///     status: StepStatus::Completed,
554///     duration_ms: 1200,
555///     cost_usd: Decimal::ZERO,
556///     input_tokens: None,
557///     output_tokens: None,
558///     error: None,
559///     output_summary: Some("ok".to_string()),
560/// };
561/// assert_eq!(result.status, StepStatus::Completed);
562/// ```
563#[derive(Debug, Clone, serde::Serialize)]
564pub struct StepResult {
565    /// Deterministic trace ID for log correlation.
566    pub trace_id: Uuid,
567    /// Step name.
568    pub name: String,
569    /// Terminal status.
570    pub status: StepStatus,
571    /// Wall-clock duration in milliseconds.
572    pub duration_ms: u64,
573    /// Cost in USD.
574    pub cost_usd: Decimal,
575    /// Input token count (agent steps only).
576    pub input_tokens: Option<u64>,
577    /// Output token count (agent steps only).
578    pub output_tokens: Option<u64>,
579    /// Error message if the step failed.
580    pub error: Option<String>,
581    /// First 500 characters of the serialized output.
582    pub output_summary: Option<String>,
583}
584
585/// Maximum length of [`StepResult::output_summary`].
586const OUTPUT_SUMMARY_MAX_LEN: usize = 500;
587
588impl StepResult {
589    /// Build from a completed step's output.
590    pub fn from_success(trace_id: Uuid, name: &str, output: &StepOutput) -> Self {
591        Self {
592            trace_id,
593            name: name.to_string(),
594            status: StepStatus::Completed,
595            duration_ms: output.duration_ms,
596            cost_usd: output.cost_usd,
597            input_tokens: output.input_tokens,
598            output_tokens: output.output_tokens,
599            error: None,
600            output_summary: summarize_output(&output.output),
601        }
602    }
603
604    /// Build from a failed step.
605    pub fn from_failure(
606        trace_id: Uuid,
607        name: &str,
608        error: &str,
609        duration_ms: u64,
610        cost_usd: Decimal,
611    ) -> Self {
612        Self {
613            trace_id,
614            name: name.to_string(),
615            status: StepStatus::Failed,
616            duration_ms,
617            cost_usd,
618            input_tokens: None,
619            output_tokens: None,
620            error: Some(error.to_string()),
621            output_summary: None,
622        }
623    }
624}
625
626fn summarize_output(value: &Value) -> Option<String> {
627    let raw = value.to_string();
628    match raw.char_indices().nth(OUTPUT_SUMMARY_MAX_LEN) {
629        None => Some(raw),
630        Some((byte_idx, _)) => Some(raw[..byte_idx].to_string()),
631    }
632}
633
634/// Trait for step executors.
635///
636/// Each step type implements this trait to execute its specific operation
637/// and return a [`StepOutput`].
638pub trait StepExecutor: Send + Sync {
639    /// The [`StepKind`] this executor handles.
640    ///
641    /// The dispatcher uses it to label spans and metrics, so a new step type
642    /// only has to declare its kind here instead of extending a match in
643    /// [`execute_step_config`].
644    fn kind(&self) -> StepKind;
645
646    /// Execute the step and return structured output.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`EngineError`] if the operation fails.
651    fn execute(
652        &self,
653        provider: &Arc<dyn AgentProvider>,
654    ) -> impl Future<Output = Result<StepOutput, EngineError>> + Send;
655}
656
657/// Span and metric label for a step kind.
658pub(crate) fn step_kind_label(kind: &StepKind) -> Cow<'static, str> {
659    match kind {
660        StepKind::Shell => Cow::Borrowed("shell"),
661        StepKind::Http => Cow::Borrowed("http"),
662        StepKind::Agent => Cow::Borrowed("agent"),
663        StepKind::Workflow => Cow::Borrowed("workflow"),
664        StepKind::Approval => Cow::Borrowed("approval"),
665        StepKind::Decision => Cow::Borrowed("decision"),
666        StepKind::HumanInput => Cow::Borrowed("human_input"),
667        StepKind::Signal => Cow::Borrowed("signal"),
668        StepKind::SignalSend => Cow::Borrowed("signal_send"),
669        StepKind::Custom(name) => Cow::Owned(name.clone()),
670    }
671}
672
673/// Execute a [`StepConfig`], letting a [`StepInterceptor`] resolve it first.
674///
675/// When `interceptor` returns `Some(result)` for this config, that result is
676/// used as-is and no executor runs. Otherwise the config is dispatched to the
677/// executor matching its [`StepKind`], exactly like [`execute_step_config`].
678///
679/// When a [`StepLogSender`] is provided, executors that support streaming
680/// will emit log lines in real time (e.g. shell stdout/stderr).
681///
682/// # Errors
683///
684/// Returns [`EngineError::Operation`] if the operation fails, or whichever
685/// error the interceptor returned for an intercepted step.
686///
687/// # Examples
688///
689/// ```no_run
690/// use ironflow_engine::config::{StepConfig, ShellConfig};
691/// use ironflow_engine::executor::execute_step_config_intercepted;
692/// use ironflow_core::provider::AgentProvider;
693/// use ironflow_core::providers::claude::ClaudeCodeProvider;
694/// use std::sync::Arc;
695///
696/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
697/// let provider: Arc<dyn AgentProvider> = Arc::new(ClaudeCodeProvider::new());
698/// let config = StepConfig::Shell(ShellConfig::new("echo hello"));
699/// let output = execute_step_config_intercepted(&config, &provider, None, None).await?;
700/// # Ok(())
701/// # }
702/// ```
703#[tracing::instrument(name = "executor.execute_step", skip_all, fields(step.kind))]
704pub async fn execute_step_config_intercepted(
705    config: &StepConfig,
706    provider: &Arc<dyn AgentProvider>,
707    log_sender: Option<StepLogSender>,
708    interceptor: Option<&Arc<dyn StepInterceptor>>,
709) -> Result<StepOutput, EngineError> {
710    let kind = config.kind();
711    let label = step_kind_label(&kind);
712    Span::current().record("step.kind", label.as_ref());
713
714    let intercepted = interceptor.and_then(|i| i.intercept(config));
715    let result = match intercepted {
716        Some(result) => result,
717        None => match config {
718            StepConfig::Shell(cfg) => {
719                let mut executor = ShellExecutor::new(cfg);
720                if let Some(sender) = log_sender {
721                    executor = executor.with_log_sender(sender);
722                }
723                executor.execute(provider).await
724            }
725            StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
726            StepConfig::Agent(cfg) => {
727                let mut executor = AgentExecutor::new(cfg);
728                if let Some(sender) = log_sender {
729                    executor = executor.with_log_sender(sender);
730                }
731                executor.execute(provider).await
732            }
733            StepConfig::Workflow(_) => Err(EngineError::StepConfig(
734                "workflow steps are executed by WorkflowContext, not the executor".to_string(),
735            )),
736            StepConfig::Approval(_) => Err(EngineError::StepConfig(
737                "approval steps are executed by WorkflowContext, not the executor".to_string(),
738            )),
739            StepConfig::Decision(_) => Err(EngineError::StepConfig(
740                "decision steps are executed by WorkflowContext, not the executor".to_string(),
741            )),
742            StepConfig::Delay(_) => Err(EngineError::StepConfig(
743                "delay steps are executed by WorkflowContext, not the executor".to_string(),
744            )),
745        },
746    };
747
748    #[cfg(feature = "prometheus")]
749    {
750        use ironflow_core::metric_names::{
751            STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
752        };
753        use metrics::{counter, histogram};
754        let status = if result.is_ok() {
755            STATUS_SUCCESS
756        } else {
757            STATUS_ERROR
758        };
759        let kind_label = label.into_owned();
760        counter!(STEPS_TOTAL, "kind" => kind_label.clone(), "status" => status).increment(1);
761        if let Ok(ref output) = result {
762            histogram!(STEP_DURATION_SECONDS, "kind" => kind_label)
763                .record(output.duration_ms as f64 / 1000.0);
764        }
765    }
766
767    result
768}
769
770/// Execute a [`StepConfig`] and return structured output.
771///
772/// When a [`StepLogSender`] is provided, executors that support streaming
773/// will emit log lines in real time (e.g. shell stdout/stderr).
774///
775/// # Errors
776///
777/// Returns [`EngineError::Operation`] if the operation fails.
778///
779/// # Examples
780///
781/// ```no_run
782/// use ironflow_engine::config::{StepConfig, ShellConfig};
783/// use ironflow_engine::executor::execute_step_config;
784/// use ironflow_core::provider::AgentProvider;
785/// use ironflow_core::providers::claude::ClaudeCodeProvider;
786/// use std::sync::Arc;
787///
788/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
789/// let provider: Arc<dyn AgentProvider> = Arc::new(ClaudeCodeProvider::new());
790/// let config = StepConfig::Shell(ShellConfig::new("echo hello"));
791/// let output = execute_step_config(&config, &provider, None).await?;
792/// # Ok(())
793/// # }
794/// ```
795pub async fn execute_step_config(
796    config: &StepConfig,
797    provider: &Arc<dyn AgentProvider>,
798    log_sender: Option<StepLogSender>,
799) -> Result<StepOutput, EngineError> {
800    execute_step_config_intercepted(config, provider, log_sender, None).await
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use ironflow_core::provider::DebugMessage;
807    use ironflow_core::providers::claude::ClaudeCodeProvider;
808    use ironflow_core::providers::record_replay::RecordReplayProvider;
809    use serde_json::json;
810
811    use crate::config::{
812        AgentStepConfig, ApprovalConfig, DecisionConfig, DelayConfig, HttpConfig, ShellConfig,
813        WorkflowStepConfig,
814    };
815
816    fn output_of(value: Value) -> StepOutput {
817        StepOutput {
818            output: value,
819            duration_ms: 1,
820            cost_usd: rust_decimal::Decimal::ZERO,
821            input_tokens: None,
822            cache_read_input_tokens: None,
823            cache_creation_input_tokens: None,
824            output_tokens: None,
825            model: None,
826            debug_messages: None,
827            artifacts: StepArtifacts::default(),
828            account_id: None,
829            environment_id: None,
830        }
831    }
832
833    #[test]
834    fn step_output_error_is_some_for_failure_output() {
835        let output = output_of(json!({"error": "boom"}));
836
837        assert_eq!(output.error(), Some("boom"));
838        assert!(!output.is_success());
839    }
840
841    #[test]
842    fn step_output_error_is_none_for_success_shell_output() {
843        let output = output_of(json!({"stdout": "hi", "stderr": "", "exit_code": 0}));
844
845        assert_eq!(output.error(), None);
846        assert!(output.is_success());
847    }
848
849    #[test]
850    fn step_output_error_is_none_for_http_and_agent_text() {
851        assert_eq!(output_of(json!({"status": 200, "body": ""})).error(), None);
852        assert_eq!(output_of(json!("fine")).error(), None);
853    }
854
855    #[test]
856    fn step_output_error_is_none_when_error_key_not_a_string() {
857        assert_eq!(output_of(json!({"error": 5})).error(), None);
858        assert_eq!(output_of(Value::Null).error(), None);
859    }
860
861    #[test]
862    fn step_output_with_no_debug_messages_returns_none() {
863        let output = StepOutput {
864            output: json!({"result": "ok"}),
865            duration_ms: 100,
866            cost_usd: rust_decimal::Decimal::ZERO,
867            input_tokens: None,
868            cache_read_input_tokens: None,
869            cache_creation_input_tokens: None,
870            output_tokens: None,
871            model: None,
872            debug_messages: None,
873            artifacts: StepArtifacts::default(),
874            account_id: None,
875            environment_id: None,
876        };
877
878        assert_eq!(output.debug_messages_json(), None);
879    }
880
881    #[test]
882    fn step_output_with_empty_debug_messages_returns_some_empty_array() {
883        let output = StepOutput {
884            output: json!({"result": "ok"}),
885            duration_ms: 100,
886            cost_usd: rust_decimal::Decimal::ZERO,
887            input_tokens: None,
888            cache_read_input_tokens: None,
889            cache_creation_input_tokens: None,
890            output_tokens: None,
891            model: None,
892            debug_messages: Some(Vec::new()),
893            artifacts: StepArtifacts::default(),
894            account_id: None,
895            environment_id: None,
896        };
897
898        let json_val = output.debug_messages_json();
899        assert!(json_val.is_some());
900        let arr = json_val.unwrap();
901        assert!(arr.is_array());
902        assert_eq!(arr.as_array().unwrap().len(), 0);
903    }
904
905    #[test]
906    fn step_output_debug_messages_json_serializes_messages() {
907        let json_msgs = json!([
908            {
909                "text": "Hello",
910                "thinking": null,
911                "thinking_redacted": false,
912                "tool_calls": [],
913                "tool_results": [],
914                "stop_reason": "end_turn",
915                "input_tokens": 10,
916                "output_tokens": 20
917            },
918            {
919                "text": "Hi there",
920                "thinking": null,
921                "thinking_redacted": false,
922                "tool_calls": [],
923                "tool_results": [],
924                "stop_reason": "end_turn",
925                "input_tokens": 15,
926                "output_tokens": 25
927            }
928        ]);
929
930        let messages: Vec<DebugMessage> =
931            serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
932
933        let output = StepOutput {
934            output: json!({"result": "ok"}),
935            duration_ms: 100,
936            cost_usd: rust_decimal::Decimal::ZERO,
937            input_tokens: None,
938            cache_read_input_tokens: None,
939            cache_creation_input_tokens: None,
940            output_tokens: None,
941            model: None,
942            debug_messages: Some(messages),
943            artifacts: StepArtifacts::default(),
944            account_id: None,
945            environment_id: None,
946        };
947
948        let json_val = output.debug_messages_json();
949        assert!(json_val.is_some());
950
951        let arr = json_val.unwrap();
952        assert!(arr.is_array());
953        let messages_array = arr.as_array().unwrap();
954        assert_eq!(messages_array.len(), 2);
955        assert_eq!(messages_array[0]["text"], "Hello");
956        assert_eq!(messages_array[1]["text"], "Hi there");
957    }
958
959    #[test]
960    fn step_output_contains_all_metrics() {
961        let output = StepOutput {
962            output: json!({"data": "test"}),
963            duration_ms: 5000,
964            cost_usd: rust_decimal::Decimal::new(123, 2),
965            input_tokens: Some(100),
966            cache_read_input_tokens: None,
967            cache_creation_input_tokens: None,
968            output_tokens: Some(200),
969            model: Some("claude-sonnet".to_string()),
970            debug_messages: None,
971            artifacts: StepArtifacts::default(),
972            account_id: None,
973            environment_id: None,
974        };
975
976        assert_eq!(output.duration_ms, 5000);
977        assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
978        assert_eq!(output.input_tokens, Some(100));
979        assert_eq!(output.output_tokens, Some(200));
980        assert_eq!(output.model, Some("claude-sonnet".to_string()));
981    }
982
983    #[test]
984    fn step_output_default_tokens_and_model_are_none() {
985        let output = StepOutput {
986            output: json!({}),
987            duration_ms: 0,
988            cost_usd: rust_decimal::Decimal::ZERO,
989            input_tokens: None,
990            cache_read_input_tokens: None,
991            cache_creation_input_tokens: None,
992            output_tokens: None,
993            model: None,
994            debug_messages: None,
995            artifacts: StepArtifacts::default(),
996            account_id: None,
997            environment_id: None,
998        };
999
1000        assert!(output.input_tokens.is_none());
1001        assert!(output.output_tokens.is_none());
1002        assert!(output.model.is_none());
1003    }
1004
1005    #[test]
1006    fn parallel_step_result_contains_step_metadata() {
1007        let step_id = uuid::Uuid::now_v7();
1008        let output = StepOutput {
1009            output: json!({"done": true}),
1010            duration_ms: 1000,
1011            cost_usd: rust_decimal::Decimal::ZERO,
1012            input_tokens: None,
1013            cache_read_input_tokens: None,
1014            cache_creation_input_tokens: None,
1015            output_tokens: None,
1016            model: None,
1017            debug_messages: None,
1018            artifacts: StepArtifacts::default(),
1019            account_id: None,
1020            environment_id: None,
1021        };
1022
1023        let result = ParallelStepResult {
1024            name: "build".to_string(),
1025            output,
1026            step_id,
1027        };
1028
1029        assert_eq!(result.name, "build");
1030        assert_eq!(result.step_id, step_id);
1031        assert_eq!(result.output.duration_ms, 1000);
1032    }
1033
1034    #[test]
1035    fn step_output_serializes_complex_json_output() {
1036        let complex_output = json!({
1037            "status": "success",
1038            "data": {
1039                "items": [1, 2, 3],
1040                "nested": {
1041                    "key": "value"
1042                }
1043            }
1044        });
1045
1046        let output = StepOutput {
1047            output: complex_output.clone(),
1048            duration_ms: 100,
1049            cost_usd: rust_decimal::Decimal::ZERO,
1050            input_tokens: None,
1051            cache_read_input_tokens: None,
1052            cache_creation_input_tokens: None,
1053            output_tokens: None,
1054            model: None,
1055            debug_messages: None,
1056            artifacts: StepArtifacts::default(),
1057            account_id: None,
1058            environment_id: None,
1059        };
1060
1061        assert_eq!(output.output, complex_output);
1062        assert_eq!(output.output["status"], "success");
1063        assert_eq!(output.output["data"]["items"][0], 1);
1064        assert_eq!(output.output["data"]["nested"]["key"], "value");
1065    }
1066
1067    #[test]
1068    fn step_result_from_success_captures_all_fields() {
1069        let trace_id = Uuid::nil();
1070        let output = StepOutput {
1071            output: json!({"stdout": "ok"}),
1072            duration_ms: 1500,
1073            cost_usd: Decimal::new(42, 2),
1074            input_tokens: Some(100),
1075            cache_read_input_tokens: None,
1076            cache_creation_input_tokens: None,
1077            output_tokens: Some(200),
1078            model: Some("claude-sonnet".to_string()),
1079            debug_messages: None,
1080            artifacts: StepArtifacts::default(),
1081            account_id: None,
1082            environment_id: None,
1083        };
1084
1085        let result = StepResult::from_success(trace_id, "build", &output);
1086
1087        assert_eq!(result.trace_id, trace_id);
1088        assert_eq!(result.name, "build");
1089        assert_eq!(result.status, StepStatus::Completed);
1090        assert_eq!(result.duration_ms, 1500);
1091        assert_eq!(result.cost_usd, Decimal::new(42, 2));
1092        assert_eq!(result.input_tokens, Some(100));
1093        assert_eq!(result.output_tokens, Some(200));
1094        assert!(result.error.is_none());
1095        assert!(result.output_summary.is_some());
1096        assert!(result.output_summary.unwrap().contains("stdout"));
1097    }
1098
1099    #[test]
1100    fn step_result_from_failure_captures_error() {
1101        let trace_id = Uuid::nil();
1102        let result =
1103            StepResult::from_failure(trace_id, "deploy", "connection refused", 500, Decimal::ZERO);
1104
1105        assert_eq!(result.trace_id, trace_id);
1106        assert_eq!(result.name, "deploy");
1107        assert_eq!(result.status, StepStatus::Failed);
1108        assert_eq!(result.duration_ms, 500);
1109        assert_eq!(result.error, Some("connection refused".to_string()));
1110        assert!(result.output_summary.is_none());
1111    }
1112
1113    #[test]
1114    fn step_result_output_summary_truncates_long_output() {
1115        let long_value = json!({"data": "x".repeat(1000)});
1116        let output = StepOutput {
1117            output: long_value,
1118            duration_ms: 0,
1119            cost_usd: Decimal::ZERO,
1120            input_tokens: None,
1121            cache_read_input_tokens: None,
1122            cache_creation_input_tokens: None,
1123            output_tokens: None,
1124            model: None,
1125            debug_messages: None,
1126            artifacts: StepArtifacts::default(),
1127            account_id: None,
1128            environment_id: None,
1129        };
1130
1131        let result = StepResult::from_success(Uuid::nil(), "test", &output);
1132        let summary = result.output_summary.unwrap();
1133        assert_eq!(summary.len(), 500);
1134    }
1135
1136    #[test]
1137    fn step_executor_kind_matches_step_config_kind() {
1138        let shell = ShellConfig::new("echo hi");
1139        let http = HttpConfig::get("https://example.com");
1140        let agent = AgentStepConfig::new("hi");
1141
1142        let shell_kind = ShellExecutor::new(&shell).kind();
1143        let http_kind = HttpExecutor::new(&http).kind();
1144        let agent_kind = AgentExecutor::new(&agent).kind();
1145
1146        assert_eq!(shell_kind, StepConfig::Shell(shell).kind());
1147        assert_eq!(http_kind, StepConfig::Http(http).kind());
1148        assert_eq!(agent_kind, StepConfig::Agent(agent).kind());
1149    }
1150
1151    #[test]
1152    fn step_kind_label_matches_dispatcher_labels() {
1153        let cases: Vec<(StepConfig, &str)> = vec![
1154            (StepConfig::Shell(ShellConfig::new("echo hi")), "shell"),
1155            (
1156                StepConfig::Http(HttpConfig::get("https://example.com")),
1157                "http",
1158            ),
1159            (StepConfig::Agent(AgentStepConfig::new("hi")), "agent"),
1160            (
1161                StepConfig::Workflow(WorkflowStepConfig::new("child", json!({}))),
1162                "workflow",
1163            ),
1164            (
1165                StepConfig::Approval(ApprovalConfig::new("approve?")),
1166                "approval",
1167            ),
1168            (
1169                StepConfig::Decision(DecisionConfig::new(json!({}))),
1170                "decision",
1171            ),
1172            (StepConfig::Delay(DelayConfig::from_secs(1)), "delay"),
1173        ];
1174
1175        for (config, expected) in cases {
1176            assert_eq!(step_kind_label(&config.kind()), expected);
1177        }
1178    }
1179
1180    #[test]
1181    fn step_kind_label_uses_the_custom_kind_name() {
1182        assert_eq!(
1183            step_kind_label(&StepKind::Custom("gitlab".to_string())),
1184            "gitlab"
1185        );
1186    }
1187
1188    /// An interceptor that resolves every shell step with a canned output.
1189    struct CannedShell;
1190
1191    impl StepInterceptor for CannedShell {
1192        fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
1193            match config {
1194                StepConfig::Shell(_) => Some(Ok(StepOutput {
1195                    output: json!({"stdout": "canned", "stderr": "", "exit_code": 0}),
1196                    duration_ms: 0,
1197                    cost_usd: Decimal::ZERO,
1198                    input_tokens: None,
1199                    cache_read_input_tokens: None,
1200                    cache_creation_input_tokens: None,
1201                    output_tokens: None,
1202                    model: None,
1203                    debug_messages: None,
1204                    artifacts: StepArtifacts::default(),
1205                    account_id: None,
1206                    environment_id: None,
1207                })),
1208                _ => None,
1209            }
1210        }
1211    }
1212
1213    fn test_provider() -> Arc<dyn AgentProvider> {
1214        let inner = ClaudeCodeProvider::new();
1215        Arc::new(RecordReplayProvider::replay(
1216            inner,
1217            "/tmp/ironflow-fixtures",
1218        ))
1219    }
1220
1221    #[tokio::test]
1222    async fn intercepted_step_never_reaches_the_shell_executor() {
1223        let interceptor: Arc<dyn StepInterceptor> = Arc::new(CannedShell);
1224        // A real run of `exit 1` would fail; the canned output proves the
1225        // process was never spawned.
1226        let config = StepConfig::Shell(ShellConfig::new("exit 1"));
1227
1228        let output =
1229            execute_step_config_intercepted(&config, &test_provider(), None, Some(&interceptor))
1230                .await
1231                .expect("the interceptor resolved the step");
1232
1233        assert_eq!(output.stdout(), "canned");
1234        assert_eq!(output.exit_code(), Some(0));
1235    }
1236
1237    #[tokio::test]
1238    async fn a_step_the_interceptor_declines_reaches_the_dispatcher() {
1239        let interceptor: Arc<dyn StepInterceptor> = Arc::new(CannedShell);
1240        // `CannedShell` only answers shell steps, so this one falls through to
1241        // the dispatcher, which refuses workflow configs.
1242        let config = StepConfig::Workflow(WorkflowStepConfig::new("child", json!({})));
1243
1244        let err =
1245            execute_step_config_intercepted(&config, &test_provider(), None, Some(&interceptor))
1246                .await
1247                .expect_err("the dispatcher rejects workflow steps");
1248
1249        assert!(matches!(err, EngineError::StepConfig(_)));
1250    }
1251
1252    #[tokio::test]
1253    async fn without_an_interceptor_the_step_runs_for_real() {
1254        let config = StepConfig::Shell(ShellConfig::new("echo hi"));
1255
1256        let output = execute_step_config_intercepted(&config, &test_provider(), None, None)
1257            .await
1258            .expect("echo succeeds");
1259
1260        assert!(output.stdout().contains("hi"));
1261        assert_eq!(output.exit_code(), Some(0));
1262    }
1263}
1264
1265#[cfg(test)]
1266mod output_helper_tests {
1267    use super::*;
1268    use serde::Deserialize;
1269    use serde_json::json;
1270
1271    fn output(value: Value) -> StepOutput {
1272        StepOutput {
1273            output: value,
1274            duration_ms: 1,
1275            cost_usd: Decimal::ZERO,
1276            input_tokens: None,
1277            cache_read_input_tokens: None,
1278            cache_creation_input_tokens: None,
1279            output_tokens: None,
1280            model: None,
1281            debug_messages: None,
1282            artifacts: StepArtifacts::default(),
1283            account_id: None,
1284            environment_id: None,
1285        }
1286    }
1287
1288    #[test]
1289    fn agent_total_tokens_includes_cache_tokens() {
1290        let mut out = output(json!("ok"));
1291        out.input_tokens = Some(100);
1292        out.cache_read_input_tokens = Some(5000);
1293        out.cache_creation_input_tokens = Some(200);
1294        out.output_tokens = Some(50);
1295        assert_eq!(out.total_tokens(), 5350);
1296    }
1297
1298    #[test]
1299    fn agent_total_tokens_all_none_is_zero() {
1300        let out = output(json!("ok"));
1301        assert_eq!(out.total_tokens(), 0);
1302    }
1303
1304    #[test]
1305    fn agent_total_tokens_saturates() {
1306        let mut out = output(json!("ok"));
1307        out.input_tokens = Some(u64::MAX);
1308        out.cache_read_input_tokens = Some(10);
1309        assert_eq!(out.total_tokens(), u64::MAX);
1310    }
1311
1312    #[test]
1313    fn shell_helpers_read_shell_fields() {
1314        let out = output(json!({"stdout": "hi\n", "stderr": "warn", "exit_code": 0}));
1315        assert_eq!(out.exit_code(), Some(0));
1316        assert_eq!(out.stdout(), "hi\n");
1317        assert_eq!(out.stderr(), "warn");
1318        assert!(out.is_success());
1319        assert_eq!(out.status(), None);
1320        assert_eq!(out.body(), "");
1321    }
1322
1323    #[test]
1324    fn shell_non_zero_exit_is_not_success() {
1325        let out = output(json!({"stdout": "", "stderr": "", "exit_code": 127}));
1326        assert_eq!(out.exit_code(), Some(127));
1327        assert!(!out.is_success());
1328    }
1329
1330    #[test]
1331    fn http_helpers_read_http_fields() {
1332        let out = output(json!({"status": 200, "body": "{\"ok\":true}"}));
1333        assert_eq!(out.status(), Some(200));
1334        assert_eq!(out.body(), "{\"ok\":true}");
1335        assert!(out.is_success());
1336        assert_eq!(out.exit_code(), None);
1337        assert_eq!(out.stdout(), "");
1338    }
1339
1340    #[test]
1341    fn http_error_status_is_not_success() {
1342        assert!(!output(json!({"status": 500, "body": ""})).is_success());
1343        assert!(!output(json!({"status": 199, "body": ""})).is_success());
1344        assert!(output(json!({"status": 299, "body": ""})).is_success());
1345    }
1346
1347    #[test]
1348    fn status_out_of_u16_range_is_none() {
1349        assert_eq!(output(json!({"status": 70000})).status(), None);
1350        assert_eq!(output(json!({"status": "200"})).status(), None);
1351    }
1352
1353    #[test]
1354    fn agent_output_without_markers_is_not_success() {
1355        let out = output(json!({"summary": "fine"}));
1356        assert!(!out.is_success());
1357        assert_eq!(out.exit_code(), None);
1358        assert_eq!(out.stdout(), "");
1359        assert_eq!(out.body(), "");
1360    }
1361
1362    #[test]
1363    fn json_deserializes_structured_output() {
1364        #[derive(Deserialize, Debug, PartialEq)]
1365        struct Review {
1366            score: u8,
1367            summary: String,
1368        }
1369        let out = output(json!({"score": 9, "summary": "good"}));
1370        let review: Review = out.json().expect("matches schema");
1371        assert_eq!(
1372            review,
1373            Review {
1374                score: 9,
1375                summary: "good".to_string()
1376            }
1377        );
1378    }
1379
1380    #[test]
1381    fn json_reports_mismatch_as_serialization_error() {
1382        #[derive(Deserialize, Debug)]
1383        struct Review {
1384            #[allow(dead_code)]
1385            score: u8,
1386        }
1387        let out = output(json!({"score": "nine"}));
1388        let err = out.json::<Review>().expect_err("type mismatch");
1389        assert!(matches!(err, EngineError::Serialization(_)));
1390    }
1391
1392    #[test]
1393    fn helpers_tolerate_non_object_output() {
1394        let out = output(json!("plain text"));
1395        assert_eq!(out.exit_code(), None);
1396        assert_eq!(out.status(), None);
1397        assert_eq!(out.stdout(), "");
1398        assert!(!out.is_success());
1399    }
1400
1401    #[test]
1402    fn text_reads_a_plain_agent_answer_only() {
1403        assert_eq!(output(json!("plain text")).text(), "plain text");
1404        assert_eq!(output(json!({"stdout": "x"})).text(), "");
1405        assert_eq!(output(Value::Null).text(), "");
1406    }
1407}