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}