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::Custom(name) => Cow::Owned(name.clone()),
669    }
670}
671
672/// Execute a [`StepConfig`], letting a [`StepInterceptor`] resolve it first.
673///
674/// When `interceptor` returns `Some(result)` for this config, that result is
675/// used as-is and no executor runs. Otherwise the config is dispatched to the
676/// executor matching its [`StepKind`], exactly like [`execute_step_config`].
677///
678/// When a [`StepLogSender`] is provided, executors that support streaming
679/// will emit log lines in real time (e.g. shell stdout/stderr).
680///
681/// # Errors
682///
683/// Returns [`EngineError::Operation`] if the operation fails, or whichever
684/// error the interceptor returned for an intercepted step.
685///
686/// # Examples
687///
688/// ```no_run
689/// use ironflow_engine::config::{StepConfig, ShellConfig};
690/// use ironflow_engine::executor::execute_step_config_intercepted;
691/// use ironflow_core::provider::AgentProvider;
692/// use ironflow_core::providers::claude::ClaudeCodeProvider;
693/// use std::sync::Arc;
694///
695/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
696/// let provider: Arc<dyn AgentProvider> = Arc::new(ClaudeCodeProvider::new());
697/// let config = StepConfig::Shell(ShellConfig::new("echo hello"));
698/// let output = execute_step_config_intercepted(&config, &provider, None, None).await?;
699/// # Ok(())
700/// # }
701/// ```
702#[tracing::instrument(name = "executor.execute_step", skip_all, fields(step.kind))]
703pub async fn execute_step_config_intercepted(
704    config: &StepConfig,
705    provider: &Arc<dyn AgentProvider>,
706    log_sender: Option<StepLogSender>,
707    interceptor: Option<&Arc<dyn StepInterceptor>>,
708) -> Result<StepOutput, EngineError> {
709    let kind = config.kind();
710    let label = step_kind_label(&kind);
711    Span::current().record("step.kind", label.as_ref());
712
713    let intercepted = interceptor.and_then(|i| i.intercept(config));
714    let result = match intercepted {
715        Some(result) => result,
716        None => match config {
717            StepConfig::Shell(cfg) => {
718                let mut executor = ShellExecutor::new(cfg);
719                if let Some(sender) = log_sender {
720                    executor = executor.with_log_sender(sender);
721                }
722                executor.execute(provider).await
723            }
724            StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
725            StepConfig::Agent(cfg) => {
726                let mut executor = AgentExecutor::new(cfg);
727                if let Some(sender) = log_sender {
728                    executor = executor.with_log_sender(sender);
729                }
730                executor.execute(provider).await
731            }
732            StepConfig::Workflow(_) => Err(EngineError::StepConfig(
733                "workflow steps are executed by WorkflowContext, not the executor".to_string(),
734            )),
735            StepConfig::Approval(_) => Err(EngineError::StepConfig(
736                "approval steps are executed by WorkflowContext, not the executor".to_string(),
737            )),
738            StepConfig::Decision(_) => Err(EngineError::StepConfig(
739                "decision steps are executed by WorkflowContext, not the executor".to_string(),
740            )),
741            StepConfig::Delay(_) => Err(EngineError::StepConfig(
742                "delay steps are executed by WorkflowContext, not the executor".to_string(),
743            )),
744        },
745    };
746
747    #[cfg(feature = "prometheus")]
748    {
749        use ironflow_core::metric_names::{
750            STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
751        };
752        use metrics::{counter, histogram};
753        let status = if result.is_ok() {
754            STATUS_SUCCESS
755        } else {
756            STATUS_ERROR
757        };
758        let kind_label = label.into_owned();
759        counter!(STEPS_TOTAL, "kind" => kind_label.clone(), "status" => status).increment(1);
760        if let Ok(ref output) = result {
761            histogram!(STEP_DURATION_SECONDS, "kind" => kind_label)
762                .record(output.duration_ms as f64 / 1000.0);
763        }
764    }
765
766    result
767}
768
769/// Execute a [`StepConfig`] and return structured output.
770///
771/// When a [`StepLogSender`] is provided, executors that support streaming
772/// will emit log lines in real time (e.g. shell stdout/stderr).
773///
774/// # Errors
775///
776/// Returns [`EngineError::Operation`] if the operation fails.
777///
778/// # Examples
779///
780/// ```no_run
781/// use ironflow_engine::config::{StepConfig, ShellConfig};
782/// use ironflow_engine::executor::execute_step_config;
783/// use ironflow_core::provider::AgentProvider;
784/// use ironflow_core::providers::claude::ClaudeCodeProvider;
785/// use std::sync::Arc;
786///
787/// # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
788/// let provider: Arc<dyn AgentProvider> = Arc::new(ClaudeCodeProvider::new());
789/// let config = StepConfig::Shell(ShellConfig::new("echo hello"));
790/// let output = execute_step_config(&config, &provider, None).await?;
791/// # Ok(())
792/// # }
793/// ```
794pub async fn execute_step_config(
795    config: &StepConfig,
796    provider: &Arc<dyn AgentProvider>,
797    log_sender: Option<StepLogSender>,
798) -> Result<StepOutput, EngineError> {
799    execute_step_config_intercepted(config, provider, log_sender, None).await
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use ironflow_core::provider::DebugMessage;
806    use ironflow_core::providers::claude::ClaudeCodeProvider;
807    use ironflow_core::providers::record_replay::RecordReplayProvider;
808    use serde_json::json;
809
810    use crate::config::{
811        AgentStepConfig, ApprovalConfig, DecisionConfig, DelayConfig, HttpConfig, ShellConfig,
812        WorkflowStepConfig,
813    };
814
815    fn output_of(value: Value) -> StepOutput {
816        StepOutput {
817            output: value,
818            duration_ms: 1,
819            cost_usd: rust_decimal::Decimal::ZERO,
820            input_tokens: None,
821            cache_read_input_tokens: None,
822            cache_creation_input_tokens: None,
823            output_tokens: None,
824            model: None,
825            debug_messages: None,
826            artifacts: StepArtifacts::default(),
827            account_id: None,
828            environment_id: None,
829        }
830    }
831
832    #[test]
833    fn step_output_error_is_some_for_failure_output() {
834        let output = output_of(json!({"error": "boom"}));
835
836        assert_eq!(output.error(), Some("boom"));
837        assert!(!output.is_success());
838    }
839
840    #[test]
841    fn step_output_error_is_none_for_success_shell_output() {
842        let output = output_of(json!({"stdout": "hi", "stderr": "", "exit_code": 0}));
843
844        assert_eq!(output.error(), None);
845        assert!(output.is_success());
846    }
847
848    #[test]
849    fn step_output_error_is_none_for_http_and_agent_text() {
850        assert_eq!(output_of(json!({"status": 200, "body": ""})).error(), None);
851        assert_eq!(output_of(json!("fine")).error(), None);
852    }
853
854    #[test]
855    fn step_output_error_is_none_when_error_key_not_a_string() {
856        assert_eq!(output_of(json!({"error": 5})).error(), None);
857        assert_eq!(output_of(Value::Null).error(), None);
858    }
859
860    #[test]
861    fn step_output_with_no_debug_messages_returns_none() {
862        let output = StepOutput {
863            output: json!({"result": "ok"}),
864            duration_ms: 100,
865            cost_usd: rust_decimal::Decimal::ZERO,
866            input_tokens: None,
867            cache_read_input_tokens: None,
868            cache_creation_input_tokens: None,
869            output_tokens: None,
870            model: None,
871            debug_messages: None,
872            artifacts: StepArtifacts::default(),
873            account_id: None,
874            environment_id: None,
875        };
876
877        assert_eq!(output.debug_messages_json(), None);
878    }
879
880    #[test]
881    fn step_output_with_empty_debug_messages_returns_some_empty_array() {
882        let output = StepOutput {
883            output: json!({"result": "ok"}),
884            duration_ms: 100,
885            cost_usd: rust_decimal::Decimal::ZERO,
886            input_tokens: None,
887            cache_read_input_tokens: None,
888            cache_creation_input_tokens: None,
889            output_tokens: None,
890            model: None,
891            debug_messages: Some(Vec::new()),
892            artifacts: StepArtifacts::default(),
893            account_id: None,
894            environment_id: None,
895        };
896
897        let json_val = output.debug_messages_json();
898        assert!(json_val.is_some());
899        let arr = json_val.unwrap();
900        assert!(arr.is_array());
901        assert_eq!(arr.as_array().unwrap().len(), 0);
902    }
903
904    #[test]
905    fn step_output_debug_messages_json_serializes_messages() {
906        let json_msgs = json!([
907            {
908                "text": "Hello",
909                "thinking": null,
910                "thinking_redacted": false,
911                "tool_calls": [],
912                "tool_results": [],
913                "stop_reason": "end_turn",
914                "input_tokens": 10,
915                "output_tokens": 20
916            },
917            {
918                "text": "Hi there",
919                "thinking": null,
920                "thinking_redacted": false,
921                "tool_calls": [],
922                "tool_results": [],
923                "stop_reason": "end_turn",
924                "input_tokens": 15,
925                "output_tokens": 25
926            }
927        ]);
928
929        let messages: Vec<DebugMessage> =
930            serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
931
932        let output = StepOutput {
933            output: json!({"result": "ok"}),
934            duration_ms: 100,
935            cost_usd: rust_decimal::Decimal::ZERO,
936            input_tokens: None,
937            cache_read_input_tokens: None,
938            cache_creation_input_tokens: None,
939            output_tokens: None,
940            model: None,
941            debug_messages: Some(messages),
942            artifacts: StepArtifacts::default(),
943            account_id: None,
944            environment_id: None,
945        };
946
947        let json_val = output.debug_messages_json();
948        assert!(json_val.is_some());
949
950        let arr = json_val.unwrap();
951        assert!(arr.is_array());
952        let messages_array = arr.as_array().unwrap();
953        assert_eq!(messages_array.len(), 2);
954        assert_eq!(messages_array[0]["text"], "Hello");
955        assert_eq!(messages_array[1]["text"], "Hi there");
956    }
957
958    #[test]
959    fn step_output_contains_all_metrics() {
960        let output = StepOutput {
961            output: json!({"data": "test"}),
962            duration_ms: 5000,
963            cost_usd: rust_decimal::Decimal::new(123, 2),
964            input_tokens: Some(100),
965            cache_read_input_tokens: None,
966            cache_creation_input_tokens: None,
967            output_tokens: Some(200),
968            model: Some("claude-sonnet".to_string()),
969            debug_messages: None,
970            artifacts: StepArtifacts::default(),
971            account_id: None,
972            environment_id: None,
973        };
974
975        assert_eq!(output.duration_ms, 5000);
976        assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
977        assert_eq!(output.input_tokens, Some(100));
978        assert_eq!(output.output_tokens, Some(200));
979        assert_eq!(output.model, Some("claude-sonnet".to_string()));
980    }
981
982    #[test]
983    fn step_output_default_tokens_and_model_are_none() {
984        let output = StepOutput {
985            output: json!({}),
986            duration_ms: 0,
987            cost_usd: rust_decimal::Decimal::ZERO,
988            input_tokens: None,
989            cache_read_input_tokens: None,
990            cache_creation_input_tokens: None,
991            output_tokens: None,
992            model: None,
993            debug_messages: None,
994            artifacts: StepArtifacts::default(),
995            account_id: None,
996            environment_id: None,
997        };
998
999        assert!(output.input_tokens.is_none());
1000        assert!(output.output_tokens.is_none());
1001        assert!(output.model.is_none());
1002    }
1003
1004    #[test]
1005    fn parallel_step_result_contains_step_metadata() {
1006        let step_id = uuid::Uuid::now_v7();
1007        let output = StepOutput {
1008            output: json!({"done": true}),
1009            duration_ms: 1000,
1010            cost_usd: rust_decimal::Decimal::ZERO,
1011            input_tokens: None,
1012            cache_read_input_tokens: None,
1013            cache_creation_input_tokens: None,
1014            output_tokens: None,
1015            model: None,
1016            debug_messages: None,
1017            artifacts: StepArtifacts::default(),
1018            account_id: None,
1019            environment_id: None,
1020        };
1021
1022        let result = ParallelStepResult {
1023            name: "build".to_string(),
1024            output,
1025            step_id,
1026        };
1027
1028        assert_eq!(result.name, "build");
1029        assert_eq!(result.step_id, step_id);
1030        assert_eq!(result.output.duration_ms, 1000);
1031    }
1032
1033    #[test]
1034    fn step_output_serializes_complex_json_output() {
1035        let complex_output = json!({
1036            "status": "success",
1037            "data": {
1038                "items": [1, 2, 3],
1039                "nested": {
1040                    "key": "value"
1041                }
1042            }
1043        });
1044
1045        let output = StepOutput {
1046            output: complex_output.clone(),
1047            duration_ms: 100,
1048            cost_usd: rust_decimal::Decimal::ZERO,
1049            input_tokens: None,
1050            cache_read_input_tokens: None,
1051            cache_creation_input_tokens: None,
1052            output_tokens: None,
1053            model: None,
1054            debug_messages: None,
1055            artifacts: StepArtifacts::default(),
1056            account_id: None,
1057            environment_id: None,
1058        };
1059
1060        assert_eq!(output.output, complex_output);
1061        assert_eq!(output.output["status"], "success");
1062        assert_eq!(output.output["data"]["items"][0], 1);
1063        assert_eq!(output.output["data"]["nested"]["key"], "value");
1064    }
1065
1066    #[test]
1067    fn step_result_from_success_captures_all_fields() {
1068        let trace_id = Uuid::nil();
1069        let output = StepOutput {
1070            output: json!({"stdout": "ok"}),
1071            duration_ms: 1500,
1072            cost_usd: Decimal::new(42, 2),
1073            input_tokens: Some(100),
1074            cache_read_input_tokens: None,
1075            cache_creation_input_tokens: None,
1076            output_tokens: Some(200),
1077            model: Some("claude-sonnet".to_string()),
1078            debug_messages: None,
1079            artifacts: StepArtifacts::default(),
1080            account_id: None,
1081            environment_id: None,
1082        };
1083
1084        let result = StepResult::from_success(trace_id, "build", &output);
1085
1086        assert_eq!(result.trace_id, trace_id);
1087        assert_eq!(result.name, "build");
1088        assert_eq!(result.status, StepStatus::Completed);
1089        assert_eq!(result.duration_ms, 1500);
1090        assert_eq!(result.cost_usd, Decimal::new(42, 2));
1091        assert_eq!(result.input_tokens, Some(100));
1092        assert_eq!(result.output_tokens, Some(200));
1093        assert!(result.error.is_none());
1094        assert!(result.output_summary.is_some());
1095        assert!(result.output_summary.unwrap().contains("stdout"));
1096    }
1097
1098    #[test]
1099    fn step_result_from_failure_captures_error() {
1100        let trace_id = Uuid::nil();
1101        let result =
1102            StepResult::from_failure(trace_id, "deploy", "connection refused", 500, Decimal::ZERO);
1103
1104        assert_eq!(result.trace_id, trace_id);
1105        assert_eq!(result.name, "deploy");
1106        assert_eq!(result.status, StepStatus::Failed);
1107        assert_eq!(result.duration_ms, 500);
1108        assert_eq!(result.error, Some("connection refused".to_string()));
1109        assert!(result.output_summary.is_none());
1110    }
1111
1112    #[test]
1113    fn step_result_output_summary_truncates_long_output() {
1114        let long_value = json!({"data": "x".repeat(1000)});
1115        let output = StepOutput {
1116            output: long_value,
1117            duration_ms: 0,
1118            cost_usd: Decimal::ZERO,
1119            input_tokens: None,
1120            cache_read_input_tokens: None,
1121            cache_creation_input_tokens: None,
1122            output_tokens: None,
1123            model: None,
1124            debug_messages: None,
1125            artifacts: StepArtifacts::default(),
1126            account_id: None,
1127            environment_id: None,
1128        };
1129
1130        let result = StepResult::from_success(Uuid::nil(), "test", &output);
1131        let summary = result.output_summary.unwrap();
1132        assert_eq!(summary.len(), 500);
1133    }
1134
1135    #[test]
1136    fn step_executor_kind_matches_step_config_kind() {
1137        let shell = ShellConfig::new("echo hi");
1138        let http = HttpConfig::get("https://example.com");
1139        let agent = AgentStepConfig::new("hi");
1140
1141        let shell_kind = ShellExecutor::new(&shell).kind();
1142        let http_kind = HttpExecutor::new(&http).kind();
1143        let agent_kind = AgentExecutor::new(&agent).kind();
1144
1145        assert_eq!(shell_kind, StepConfig::Shell(shell).kind());
1146        assert_eq!(http_kind, StepConfig::Http(http).kind());
1147        assert_eq!(agent_kind, StepConfig::Agent(agent).kind());
1148    }
1149
1150    #[test]
1151    fn step_kind_label_matches_dispatcher_labels() {
1152        let cases: Vec<(StepConfig, &str)> = vec![
1153            (StepConfig::Shell(ShellConfig::new("echo hi")), "shell"),
1154            (
1155                StepConfig::Http(HttpConfig::get("https://example.com")),
1156                "http",
1157            ),
1158            (StepConfig::Agent(AgentStepConfig::new("hi")), "agent"),
1159            (
1160                StepConfig::Workflow(WorkflowStepConfig::new("child", json!({}))),
1161                "workflow",
1162            ),
1163            (
1164                StepConfig::Approval(ApprovalConfig::new("approve?")),
1165                "approval",
1166            ),
1167            (
1168                StepConfig::Decision(DecisionConfig::new(json!({}))),
1169                "decision",
1170            ),
1171            (StepConfig::Delay(DelayConfig::from_secs(1)), "delay"),
1172        ];
1173
1174        for (config, expected) in cases {
1175            assert_eq!(step_kind_label(&config.kind()), expected);
1176        }
1177    }
1178
1179    #[test]
1180    fn step_kind_label_uses_the_custom_kind_name() {
1181        assert_eq!(
1182            step_kind_label(&StepKind::Custom("gitlab".to_string())),
1183            "gitlab"
1184        );
1185    }
1186
1187    /// An interceptor that resolves every shell step with a canned output.
1188    struct CannedShell;
1189
1190    impl StepInterceptor for CannedShell {
1191        fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
1192            match config {
1193                StepConfig::Shell(_) => Some(Ok(StepOutput {
1194                    output: json!({"stdout": "canned", "stderr": "", "exit_code": 0}),
1195                    duration_ms: 0,
1196                    cost_usd: Decimal::ZERO,
1197                    input_tokens: None,
1198                    cache_read_input_tokens: None,
1199                    cache_creation_input_tokens: None,
1200                    output_tokens: None,
1201                    model: None,
1202                    debug_messages: None,
1203                    artifacts: StepArtifacts::default(),
1204                    account_id: None,
1205                    environment_id: None,
1206                })),
1207                _ => None,
1208            }
1209        }
1210    }
1211
1212    fn test_provider() -> Arc<dyn AgentProvider> {
1213        let inner = ClaudeCodeProvider::new();
1214        Arc::new(RecordReplayProvider::replay(
1215            inner,
1216            "/tmp/ironflow-fixtures",
1217        ))
1218    }
1219
1220    #[tokio::test]
1221    async fn intercepted_step_never_reaches_the_shell_executor() {
1222        let interceptor: Arc<dyn StepInterceptor> = Arc::new(CannedShell);
1223        // A real run of `exit 1` would fail; the canned output proves the
1224        // process was never spawned.
1225        let config = StepConfig::Shell(ShellConfig::new("exit 1"));
1226
1227        let output =
1228            execute_step_config_intercepted(&config, &test_provider(), None, Some(&interceptor))
1229                .await
1230                .expect("the interceptor resolved the step");
1231
1232        assert_eq!(output.stdout(), "canned");
1233        assert_eq!(output.exit_code(), Some(0));
1234    }
1235
1236    #[tokio::test]
1237    async fn a_step_the_interceptor_declines_reaches_the_dispatcher() {
1238        let interceptor: Arc<dyn StepInterceptor> = Arc::new(CannedShell);
1239        // `CannedShell` only answers shell steps, so this one falls through to
1240        // the dispatcher, which refuses workflow configs.
1241        let config = StepConfig::Workflow(WorkflowStepConfig::new("child", json!({})));
1242
1243        let err =
1244            execute_step_config_intercepted(&config, &test_provider(), None, Some(&interceptor))
1245                .await
1246                .expect_err("the dispatcher rejects workflow steps");
1247
1248        assert!(matches!(err, EngineError::StepConfig(_)));
1249    }
1250
1251    #[tokio::test]
1252    async fn without_an_interceptor_the_step_runs_for_real() {
1253        let config = StepConfig::Shell(ShellConfig::new("echo hi"));
1254
1255        let output = execute_step_config_intercepted(&config, &test_provider(), None, None)
1256            .await
1257            .expect("echo succeeds");
1258
1259        assert!(output.stdout().contains("hi"));
1260        assert_eq!(output.exit_code(), Some(0));
1261    }
1262}
1263
1264#[cfg(test)]
1265mod output_helper_tests {
1266    use super::*;
1267    use serde::Deserialize;
1268    use serde_json::json;
1269
1270    fn output(value: Value) -> StepOutput {
1271        StepOutput {
1272            output: value,
1273            duration_ms: 1,
1274            cost_usd: Decimal::ZERO,
1275            input_tokens: None,
1276            cache_read_input_tokens: None,
1277            cache_creation_input_tokens: None,
1278            output_tokens: None,
1279            model: None,
1280            debug_messages: None,
1281            artifacts: StepArtifacts::default(),
1282            account_id: None,
1283            environment_id: None,
1284        }
1285    }
1286
1287    #[test]
1288    fn agent_total_tokens_includes_cache_tokens() {
1289        let mut out = output(json!("ok"));
1290        out.input_tokens = Some(100);
1291        out.cache_read_input_tokens = Some(5000);
1292        out.cache_creation_input_tokens = Some(200);
1293        out.output_tokens = Some(50);
1294        assert_eq!(out.total_tokens(), 5350);
1295    }
1296
1297    #[test]
1298    fn agent_total_tokens_all_none_is_zero() {
1299        let out = output(json!("ok"));
1300        assert_eq!(out.total_tokens(), 0);
1301    }
1302
1303    #[test]
1304    fn agent_total_tokens_saturates() {
1305        let mut out = output(json!("ok"));
1306        out.input_tokens = Some(u64::MAX);
1307        out.cache_read_input_tokens = Some(10);
1308        assert_eq!(out.total_tokens(), u64::MAX);
1309    }
1310
1311    #[test]
1312    fn shell_helpers_read_shell_fields() {
1313        let out = output(json!({"stdout": "hi\n", "stderr": "warn", "exit_code": 0}));
1314        assert_eq!(out.exit_code(), Some(0));
1315        assert_eq!(out.stdout(), "hi\n");
1316        assert_eq!(out.stderr(), "warn");
1317        assert!(out.is_success());
1318        assert_eq!(out.status(), None);
1319        assert_eq!(out.body(), "");
1320    }
1321
1322    #[test]
1323    fn shell_non_zero_exit_is_not_success() {
1324        let out = output(json!({"stdout": "", "stderr": "", "exit_code": 127}));
1325        assert_eq!(out.exit_code(), Some(127));
1326        assert!(!out.is_success());
1327    }
1328
1329    #[test]
1330    fn http_helpers_read_http_fields() {
1331        let out = output(json!({"status": 200, "body": "{\"ok\":true}"}));
1332        assert_eq!(out.status(), Some(200));
1333        assert_eq!(out.body(), "{\"ok\":true}");
1334        assert!(out.is_success());
1335        assert_eq!(out.exit_code(), None);
1336        assert_eq!(out.stdout(), "");
1337    }
1338
1339    #[test]
1340    fn http_error_status_is_not_success() {
1341        assert!(!output(json!({"status": 500, "body": ""})).is_success());
1342        assert!(!output(json!({"status": 199, "body": ""})).is_success());
1343        assert!(output(json!({"status": 299, "body": ""})).is_success());
1344    }
1345
1346    #[test]
1347    fn status_out_of_u16_range_is_none() {
1348        assert_eq!(output(json!({"status": 70000})).status(), None);
1349        assert_eq!(output(json!({"status": "200"})).status(), None);
1350    }
1351
1352    #[test]
1353    fn agent_output_without_markers_is_not_success() {
1354        let out = output(json!({"summary": "fine"}));
1355        assert!(!out.is_success());
1356        assert_eq!(out.exit_code(), None);
1357        assert_eq!(out.stdout(), "");
1358        assert_eq!(out.body(), "");
1359    }
1360
1361    #[test]
1362    fn json_deserializes_structured_output() {
1363        #[derive(Deserialize, Debug, PartialEq)]
1364        struct Review {
1365            score: u8,
1366            summary: String,
1367        }
1368        let out = output(json!({"score": 9, "summary": "good"}));
1369        let review: Review = out.json().expect("matches schema");
1370        assert_eq!(
1371            review,
1372            Review {
1373                score: 9,
1374                summary: "good".to_string()
1375            }
1376        );
1377    }
1378
1379    #[test]
1380    fn json_reports_mismatch_as_serialization_error() {
1381        #[derive(Deserialize, Debug)]
1382        struct Review {
1383            #[allow(dead_code)]
1384            score: u8,
1385        }
1386        let out = output(json!({"score": "nine"}));
1387        let err = out.json::<Review>().expect_err("type mismatch");
1388        assert!(matches!(err, EngineError::Serialization(_)));
1389    }
1390
1391    #[test]
1392    fn helpers_tolerate_non_object_output() {
1393        let out = output(json!("plain text"));
1394        assert_eq!(out.exit_code(), None);
1395        assert_eq!(out.status(), None);
1396        assert_eq!(out.stdout(), "");
1397        assert!(!out.is_success());
1398    }
1399
1400    #[test]
1401    fn text_reads_a_plain_agent_answer_only() {
1402        assert_eq!(output(json!("plain text")).text(), "plain text");
1403        assert_eq!(output(json!({"stdout": "x"})).text(), "");
1404        assert_eq!(output(Value::Null).text(), "");
1405    }
1406}