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