Skip to main content

ironflow_core/
provider.rs

1//! Provider trait and configuration types for agent invocations.
2//!
3//! The [`AgentProvider`] trait is the primary extension point in ironflow: implement it
4//! to plug in any AI backend (local model, HTTP API, mock, etc.) without changing
5//! your workflow code.
6//!
7//! Built-in implementations:
8//!
9//! * [`ClaudeCodeProvider`](crate::providers::claude::ClaudeCodeProvider) - local `claude` CLI.
10//! * `SshProvider` - remote via SSH (requires `transport-ssh` feature).
11//! * `DockerProvider` - Docker container (requires `transport-docker` feature).
12//! * `K8sEphemeralProvider` - ephemeral K8s pod (requires `transport-k8s` feature).
13//! * `K8sPersistentProvider` - persistent K8s pod (requires `transport-k8s` feature).
14//! * [`RecordReplayProvider`](crate::providers::record_replay::RecordReplayProvider) -
15//!   records and replays fixtures for deterministic testing.
16
17use std::fmt;
18use std::future::Future;
19use std::marker::PhantomData;
20use std::pin::Pin;
21
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::error::AgentError;
27use crate::operations::agent::{Model, PermissionMode};
28
29/// Boxed future returned by [`AgentProvider::invoke`].
30pub type InvokeFuture<'a> =
31    Pin<Box<dyn Future<Output = Result<AgentOutput, AgentError>> + Send + 'a>>;
32
33// ── Typestate markers ──────────────────────────────────────────────
34
35/// Marker: no tools have been added via the builder.
36#[derive(Debug, Clone, Copy)]
37pub struct NoTools;
38
39/// Marker: at least one tool has been added via [`AgentConfig::allow_tool`].
40#[derive(Debug, Clone, Copy)]
41pub struct WithTools;
42
43/// Marker: no JSON schema has been set via the builder.
44#[derive(Debug, Clone, Copy)]
45pub struct NoSchema;
46
47/// Marker: a JSON schema has been set via [`AgentConfig::output`] or
48/// [`AgentConfig::output_schema_raw`].
49#[derive(Debug, Clone, Copy)]
50pub struct WithSchema;
51
52// ── AgentConfig ────────────────────────────────────────────────────
53
54/// Serializable configuration passed to an [`AgentProvider`] for a single invocation.
55///
56/// Built by [`Agent::run`](crate::operations::agent::Agent::run) from the builder state.
57/// Provider implementations translate these fields into whatever format the underlying
58/// backend expects.
59///
60/// # Typestate: tools vs structured output
61///
62/// Claude CLI has a [known bug](https://github.com/anthropics/claude-code/issues/18536)
63/// where combining `--json-schema` with `--allowedTools` always returns
64/// `structured_output: null`. To prevent this at compile time, [`allow_tool`](Self::allow_tool)
65/// and [`output`](Self::output) / [`output_schema_raw`](Self::output_schema_raw) are mutually
66/// exclusive: using one removes the other from the available API.
67///
68/// ```
69/// use ironflow_core::provider::AgentConfig;
70///
71/// // OK: tools only
72/// let _ = AgentConfig::new("search").allow_tool("WebSearch");
73///
74/// // OK: structured output only
75/// let _ = AgentConfig::new("classify").output_schema_raw(r#"{"type":"object"}"#);
76/// ```
77///
78/// ```compile_fail
79/// use ironflow_core::provider::AgentConfig;
80/// // COMPILE ERROR: cannot add tools after setting structured output
81/// let _ = AgentConfig::new("x").output_schema_raw("{}").allow_tool("Read");
82/// ```
83///
84/// ```compile_fail
85/// use ironflow_core::provider::AgentConfig;
86/// // COMPILE ERROR: cannot set structured output after adding tools
87/// let _ = AgentConfig::new("x").allow_tool("Read").output_schema_raw("{}");
88/// ```
89///
90/// **Workaround**: split the work into two steps -- one agent with tools to
91/// gather data, then a second agent with `.output::<T>()` to structure the result.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(bound(serialize = "", deserialize = ""))]
94#[non_exhaustive]
95pub struct AgentConfig<Tools = NoTools, Schema = NoSchema> {
96    /// Optional system prompt that sets the agent's persona or constraints.
97    pub system_prompt: Option<String>,
98
99    /// The user prompt - the main instruction to the agent.
100    pub prompt: String,
101
102    /// Which model to use for this invocation.
103    ///
104    /// Accepts any string. Use [`Model`] constants for well-known Claude models
105    /// (e.g. `Model::SONNET`), or pass a custom identifier for other providers.
106    #[serde(default = "default_model")]
107    pub model: String,
108
109    /// Allowlist of tool names the agent may invoke (empty = provider default).
110    #[serde(default)]
111    pub allowed_tools: Vec<String>,
112
113    /// Maximum number of agentic turns before the provider should stop.
114    pub max_turns: Option<u32>,
115
116    /// Maximum spend in USD for this single invocation.
117    pub max_budget_usd: Option<f64>,
118
119    /// Working directory for the agent process.
120    pub working_dir: Option<String>,
121
122    /// Path to an MCP server configuration file.
123    pub mcp_config: Option<String>,
124
125    /// Permission mode controlling how the agent handles tool-use approvals.
126    #[serde(default)]
127    pub permission_mode: PermissionMode,
128
129    /// Optional JSON Schema string. When set, the provider should request
130    /// structured (typed) output from the model.
131    #[serde(alias = "output_schema")]
132    pub json_schema: Option<String>,
133
134    /// Optional session ID to resume a previous conversation.
135    ///
136    /// When set, the provider should continue the conversation from the
137    /// specified session rather than starting a new one.
138    pub resume_session_id: Option<String>,
139
140    /// Enable verbose/debug mode to capture the full conversation trace.
141    ///
142    /// When `true`, the provider uses streaming output (`stream-json`) to
143    /// record every assistant message and tool call. The resulting
144    /// [`AgentOutput::debug_messages`] field will contain the conversation
145    /// trace for inspection.
146    #[serde(default)]
147    pub verbose: bool,
148
149    /// Zero-sized typestate marker (not serialized).
150    #[serde(skip)]
151    pub(crate) _marker: PhantomData<(Tools, Schema)>,
152}
153
154fn default_model() -> String {
155    Model::SONNET.to_string()
156}
157
158// ── Constructor (base type only) ───────────────────────────────────
159
160impl AgentConfig {
161    /// Create an `AgentConfig` with required fields and defaults for the rest.
162    pub fn new(prompt: &str) -> Self {
163        Self {
164            system_prompt: None,
165            prompt: prompt.to_string(),
166            model: Model::SONNET.to_string(),
167            allowed_tools: Vec::new(),
168            max_turns: None,
169            max_budget_usd: None,
170            working_dir: None,
171            mcp_config: None,
172            permission_mode: PermissionMode::Default,
173            json_schema: None,
174            resume_session_id: None,
175            verbose: false,
176            _marker: PhantomData,
177        }
178    }
179}
180
181// ── Methods available on ALL typestate variants ────────────────────
182
183impl<Tools, Schema> AgentConfig<Tools, Schema> {
184    /// Set the system prompt.
185    pub fn system_prompt(mut self, prompt: &str) -> Self {
186        self.system_prompt = Some(prompt.to_string());
187        self
188    }
189
190    /// Set the model name.
191    pub fn model(mut self, model: &str) -> Self {
192        self.model = model.to_string();
193        self
194    }
195
196    /// Set the maximum budget in USD.
197    pub fn max_budget_usd(mut self, budget: f64) -> Self {
198        self.max_budget_usd = Some(budget);
199        self
200    }
201
202    /// Set the maximum number of turns.
203    pub fn max_turns(mut self, turns: u32) -> Self {
204        self.max_turns = Some(turns);
205        self
206    }
207
208    /// Set the working directory.
209    pub fn working_dir(mut self, dir: &str) -> Self {
210        self.working_dir = Some(dir.to_string());
211        self
212    }
213
214    /// Set the permission mode.
215    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
216        self.permission_mode = mode;
217        self
218    }
219
220    /// Enable verbose/debug mode.
221    pub fn verbose(mut self, enabled: bool) -> Self {
222        self.verbose = enabled;
223        self
224    }
225
226    /// Set the MCP server configuration file path.
227    pub fn mcp_config(mut self, config: &str) -> Self {
228        self.mcp_config = Some(config.to_string());
229        self
230    }
231
232    /// Set a session ID to resume a previous conversation.
233    pub fn resume(mut self, session_id: &str) -> Self {
234        self.resume_session_id = Some(session_id.to_string());
235        self
236    }
237
238    /// Convert to a different typestate by moving all fields.
239    ///
240    /// Safe because the marker is a zero-sized [`PhantomData`] -- no
241    /// runtime data changes.
242    fn change_state<T2, S2>(self) -> AgentConfig<T2, S2> {
243        AgentConfig {
244            system_prompt: self.system_prompt,
245            prompt: self.prompt,
246            model: self.model,
247            allowed_tools: self.allowed_tools,
248            max_turns: self.max_turns,
249            max_budget_usd: self.max_budget_usd,
250            working_dir: self.working_dir,
251            mcp_config: self.mcp_config,
252            permission_mode: self.permission_mode,
253            json_schema: self.json_schema,
254            resume_session_id: self.resume_session_id,
255            verbose: self.verbose,
256            _marker: PhantomData,
257        }
258    }
259}
260
261// ── allow_tool: only when no schema is set ─────────────────────────
262
263impl<Tools> AgentConfig<Tools, NoSchema> {
264    /// Add an allowed tool.
265    ///
266    /// Can be called multiple times to allow several tools. Returns an
267    /// [`AgentConfig<WithTools, NoSchema>`], which **cannot** call
268    /// [`output`](AgentConfig::output) or [`output_schema_raw`](AgentConfig::output_schema_raw).
269    ///
270    /// This restriction exists because Claude CLI has a
271    /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
272    /// where `--json-schema` combined with `--allowedTools` always returns
273    /// `structured_output: null`.
274    ///
275    /// **Workaround**: use two sequential agent steps -- one with tools to
276    /// gather data, then one with `.output::<T>()` to structure the result.
277    ///
278    /// # Examples
279    ///
280    /// ```
281    /// use ironflow_core::provider::AgentConfig;
282    ///
283    /// let config = AgentConfig::new("search the web")
284    ///     .allow_tool("WebSearch")
285    ///     .allow_tool("WebFetch");
286    /// ```
287    ///
288    /// ```compile_fail
289    /// use ironflow_core::provider::AgentConfig;
290    /// // ERROR: cannot set structured output after adding tools
291    /// let _ = AgentConfig::new("x")
292    ///     .allow_tool("Read")
293    ///     .output_schema_raw(r#"{"type":"object"}"#);
294    /// ```
295    pub fn allow_tool(mut self, tool: &str) -> AgentConfig<WithTools, NoSchema> {
296        self.allowed_tools.push(tool.to_string());
297        self.change_state()
298    }
299}
300
301// ── output: only when no tools are set ─────────────────────────────
302
303impl<Schema> AgentConfig<NoTools, Schema> {
304    /// Set structured output from a Rust type implementing [`JsonSchema`].
305    ///
306    /// The schema is serialized once at build time. When set, the provider
307    /// will request typed output conforming to this schema.
308    ///
309    /// **Important:** structured output requires `max_turns >= 2`.
310    ///
311    /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
312    /// call [`allow_tool`](AgentConfig::allow_tool).
313    ///
314    /// This restriction exists because Claude CLI has a
315    /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
316    /// where `--json-schema` combined with `--allowedTools` always returns
317    /// `structured_output: null`.
318    ///
319    /// **Workaround**: use two sequential agent steps -- one with tools to
320    /// gather data, then one with `.output::<T>()` to structure the result.
321    ///
322    /// # Known limitations of Claude CLI structured output
323    ///
324    /// The Claude CLI does not guarantee strict schema conformance for
325    /// structured output. The following upstream bugs affect the behavior:
326    ///
327    /// - **Schema flattening** ([anthropics/claude-agent-sdk-python#502]):
328    ///   a schema like `{"type":"object","properties":{"items":{"type":"array",...}}}`
329    ///   may return a bare array instead of the wrapper object. The CLI
330    ///   non-deterministically flattens schemas with a single array field.
331    /// - **Non-deterministic wrapping** ([anthropics/claude-agent-sdk-python#374]):
332    ///   the same prompt can produce differently wrapped output across runs.
333    /// - **No conformance guarantee** ([anthropics/claude-code#9058]):
334    ///   the CLI does not validate output against the provided JSON schema.
335    ///
336    /// Because of these bugs, ironflow's provider layer applies multiple
337    /// fallback strategies when extracting the structured value (see
338    /// [`extract_structured_value`](crate::providers::claude::common::extract_structured_value)).
339    ///
340    /// [anthropics/claude-agent-sdk-python#502]: https://github.com/anthropics/claude-agent-sdk-python/issues/502
341    /// [anthropics/claude-agent-sdk-python#374]: https://github.com/anthropics/claude-agent-sdk-python/issues/374
342    /// [anthropics/claude-code#9058]: https://github.com/anthropics/claude-code/issues/9058
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use ironflow_core::provider::AgentConfig;
348    /// use schemars::JsonSchema;
349    ///
350    /// #[derive(serde::Deserialize, JsonSchema)]
351    /// struct Labels { labels: Vec<String> }
352    ///
353    /// let config = AgentConfig::new("classify this text")
354    ///     .output::<Labels>();
355    /// ```
356    ///
357    /// ```compile_fail
358    /// use ironflow_core::provider::AgentConfig;
359    /// use schemars::JsonSchema;
360    /// #[derive(serde::Deserialize, JsonSchema)]
361    /// struct Out { x: i32 }
362    /// // ERROR: cannot add tools after setting structured output
363    /// let _ = AgentConfig::new("x").output::<Out>().allow_tool("Read");
364    /// ```
365    /// # Panics
366    ///
367    /// Panics if the schema generated by `schemars` cannot be serialized
368    /// to JSON. This indicates a bug in the type's `JsonSchema` derive,
369    /// not a recoverable runtime error.
370    pub fn output<T: JsonSchema>(mut self) -> AgentConfig<NoTools, WithSchema> {
371        let schema = schemars::schema_for!(T);
372        let serialized = serde_json::to_string(&schema).unwrap_or_else(|e| {
373            panic!(
374                "failed to serialize JSON schema for {}: {e}",
375                std::any::type_name::<T>()
376            )
377        });
378        self.json_schema = Some(serialized);
379        self.change_state()
380    }
381
382    /// Set structured output from a pre-serialized JSON Schema string.
383    ///
384    /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
385    /// call [`allow_tool`](AgentConfig::allow_tool). See [`output`](Self::output)
386    /// for the rationale and workaround.
387    pub fn output_schema_raw(mut self, schema: &str) -> AgentConfig<NoTools, WithSchema> {
388        self.json_schema = Some(schema.to_string());
389        self.change_state()
390    }
391}
392
393// ── From conversions to base type ──────────────────────────────────
394
395impl From<AgentConfig<WithTools, NoSchema>> for AgentConfig {
396    fn from(config: AgentConfig<WithTools, NoSchema>) -> Self {
397        config.change_state()
398    }
399}
400
401impl From<AgentConfig<NoTools, WithSchema>> for AgentConfig {
402    fn from(config: AgentConfig<NoTools, WithSchema>) -> Self {
403        config.change_state()
404    }
405}
406
407// ── AgentOutput ────────────────────────────────────────────────────
408
409/// Raw output returned by an [`AgentProvider`] after a successful invocation.
410///
411/// Carries the agent's response value together with usage and billing metadata.
412#[derive(Clone, Debug, Serialize, Deserialize)]
413#[non_exhaustive]
414pub struct AgentOutput {
415    /// The agent's response. A plain [`Value::String`] for text mode, or an
416    /// arbitrary JSON value when a JSON schema was requested.
417    pub value: Value,
418
419    /// Provider-assigned session identifier, useful for resuming conversations.
420    pub session_id: Option<String>,
421
422    /// Total cost in USD for this invocation, if reported by the provider.
423    pub cost_usd: Option<f64>,
424
425    /// Number of input tokens consumed, if reported.
426    pub input_tokens: Option<u64>,
427
428    /// Number of output tokens generated, if reported.
429    pub output_tokens: Option<u64>,
430
431    /// The concrete model identifier used (e.g. `"claude-sonnet-4-20250514"`).
432    pub model: Option<String>,
433
434    /// Wall-clock duration of the invocation in milliseconds.
435    pub duration_ms: u64,
436
437    /// Conversation trace captured when [`AgentConfig::verbose`] is `true`.
438    ///
439    /// Contains every assistant message and tool call made during the
440    /// invocation, in chronological order. `None` when verbose mode is off.
441    pub debug_messages: Option<Vec<DebugMessage>>,
442}
443
444/// A single assistant turn captured during a verbose invocation.
445///
446/// Each `DebugMessage` represents one assistant response, which may contain
447/// free-form text, tool calls, or both.
448///
449/// # Examples
450///
451/// ```no_run
452/// use ironflow_core::prelude::*;
453///
454/// # async fn example() -> Result<(), OperationError> {
455/// let provider = ClaudeCodeProvider::new();
456/// let result = Agent::new()
457///     .prompt("List files in src/")
458///     .verbose()
459///     .run(&provider)
460///     .await?;
461///
462/// if let Some(messages) = result.debug_messages() {
463///     for msg in messages {
464///         println!("{msg}");
465///     }
466/// }
467/// # Ok(())
468/// # }
469/// ```
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[non_exhaustive]
472pub struct DebugMessage {
473    /// Free-form text produced by the assistant in this turn, if any.
474    pub text: Option<String>,
475
476    /// Tool calls made by the assistant in this turn.
477    pub tool_calls: Vec<DebugToolCall>,
478
479    /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
480    pub stop_reason: Option<String>,
481}
482
483impl fmt::Display for DebugMessage {
484    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
485        if let Some(ref text) = self.text {
486            writeln!(f, "[assistant] {text}")?;
487        }
488        for tc in &self.tool_calls {
489            write!(f, "{tc}")?;
490        }
491        Ok(())
492    }
493}
494
495/// A single tool call captured during a verbose invocation.
496///
497/// Records the tool name and its input arguments as a raw JSON value.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499#[non_exhaustive]
500pub struct DebugToolCall {
501    /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
502    pub name: String,
503
504    /// Input arguments passed to the tool, as raw JSON.
505    pub input: Value,
506}
507
508impl fmt::Display for DebugToolCall {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        writeln!(f, "  [tool_use] {} -> {}", self.name, self.input)
511    }
512}
513
514impl AgentOutput {
515    /// Create an `AgentOutput` with the given value and sensible defaults.
516    pub fn new(value: Value) -> Self {
517        Self {
518            value,
519            session_id: None,
520            cost_usd: None,
521            input_tokens: None,
522            output_tokens: None,
523            model: None,
524            duration_ms: 0,
525            debug_messages: None,
526        }
527    }
528}
529
530// ── Provider trait ─────────────────────────────────────────────────
531
532/// Trait for AI agent backends.
533///
534/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
535/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
536/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
537///
538/// # Examples
539///
540/// ```no_run
541/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
542///
543/// struct MyProvider;
544///
545/// impl AgentProvider for MyProvider {
546///     fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
547///         Box::pin(async move {
548///             // Call your custom backend here...
549///             todo!()
550///         })
551///     }
552/// }
553/// ```
554pub trait AgentProvider: Send + Sync {
555    /// Execute a single agent invocation with the given configuration.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`AgentError`] if the underlying backend process fails,
560    /// times out, or produces output that does not match the requested schema.
561    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use serde_json::json;
568
569    fn full_config() -> AgentConfig {
570        AgentConfig {
571            system_prompt: Some("you are helpful".to_string()),
572            prompt: "do stuff".to_string(),
573            model: Model::OPUS.to_string(),
574            allowed_tools: vec!["Read".to_string(), "Write".to_string()],
575            max_turns: Some(10),
576            max_budget_usd: Some(2.5),
577            working_dir: Some("/tmp".to_string()),
578            mcp_config: Some("{}".to_string()),
579            permission_mode: PermissionMode::Auto,
580            json_schema: Some(r#"{"type":"object"}"#.to_string()),
581            resume_session_id: None,
582            verbose: false,
583            _marker: PhantomData,
584        }
585    }
586
587    #[test]
588    fn agent_config_serialize_deserialize_roundtrip() {
589        let config = full_config();
590        let json = serde_json::to_string(&config).unwrap();
591        let back: AgentConfig = serde_json::from_str(&json).unwrap();
592
593        assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
594        assert_eq!(back.prompt, "do stuff");
595        assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
596        assert_eq!(back.max_turns, Some(10));
597        assert_eq!(back.max_budget_usd, Some(2.5));
598        assert_eq!(back.working_dir, Some("/tmp".to_string()));
599        assert_eq!(back.mcp_config, Some("{}".to_string()));
600        assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
601    }
602
603    #[test]
604    fn agent_config_with_all_optional_fields_none() {
605        let config: AgentConfig = AgentConfig {
606            system_prompt: None,
607            prompt: "hello".to_string(),
608            model: Model::HAIKU.to_string(),
609            allowed_tools: vec![],
610            max_turns: None,
611            max_budget_usd: None,
612            working_dir: None,
613            mcp_config: None,
614            permission_mode: PermissionMode::Default,
615            json_schema: None,
616            resume_session_id: None,
617            verbose: false,
618            _marker: PhantomData,
619        };
620        let json = serde_json::to_string(&config).unwrap();
621        let back: AgentConfig = serde_json::from_str(&json).unwrap();
622
623        assert_eq!(back.system_prompt, None);
624        assert_eq!(back.prompt, "hello");
625        assert!(back.allowed_tools.is_empty());
626        assert_eq!(back.max_turns, None);
627        assert_eq!(back.max_budget_usd, None);
628        assert_eq!(back.working_dir, None);
629        assert_eq!(back.mcp_config, None);
630        assert_eq!(back.json_schema, None);
631    }
632
633    #[test]
634    fn agent_output_serialize_deserialize_roundtrip() {
635        let output = AgentOutput {
636            value: json!({"key": "value"}),
637            session_id: Some("sess-abc".to_string()),
638            cost_usd: Some(0.01),
639            input_tokens: Some(500),
640            output_tokens: Some(200),
641            model: Some("claude-sonnet".to_string()),
642            duration_ms: 3000,
643            debug_messages: None,
644        };
645        let json = serde_json::to_string(&output).unwrap();
646        let back: AgentOutput = serde_json::from_str(&json).unwrap();
647
648        assert_eq!(back.value, json!({"key": "value"}));
649        assert_eq!(back.session_id, Some("sess-abc".to_string()));
650        assert_eq!(back.cost_usd, Some(0.01));
651        assert_eq!(back.input_tokens, Some(500));
652        assert_eq!(back.output_tokens, Some(200));
653        assert_eq!(back.model, Some("claude-sonnet".to_string()));
654        assert_eq!(back.duration_ms, 3000);
655    }
656
657    #[test]
658    fn agent_config_new_has_correct_defaults() {
659        let config = AgentConfig::new("test prompt");
660        assert_eq!(config.prompt, "test prompt");
661        assert_eq!(config.system_prompt, None);
662        assert_eq!(config.model, Model::SONNET);
663        assert!(config.allowed_tools.is_empty());
664        assert_eq!(config.max_turns, None);
665        assert_eq!(config.max_budget_usd, None);
666        assert_eq!(config.working_dir, None);
667        assert_eq!(config.mcp_config, None);
668        assert!(matches!(config.permission_mode, PermissionMode::Default));
669        assert_eq!(config.json_schema, None);
670        assert_eq!(config.resume_session_id, None);
671        assert!(!config.verbose);
672    }
673
674    #[test]
675    fn agent_output_new_has_correct_defaults() {
676        let output = AgentOutput::new(json!("test"));
677        assert_eq!(output.value, json!("test"));
678        assert_eq!(output.session_id, None);
679        assert_eq!(output.cost_usd, None);
680        assert_eq!(output.input_tokens, None);
681        assert_eq!(output.output_tokens, None);
682        assert_eq!(output.model, None);
683        assert_eq!(output.duration_ms, 0);
684        assert!(output.debug_messages.is_none());
685    }
686
687    #[test]
688    fn agent_config_resume_session_roundtrip() {
689        let mut config = AgentConfig::new("test");
690        config.resume_session_id = Some("sess-xyz".to_string());
691        let json = serde_json::to_string(&config).unwrap();
692        let back: AgentConfig = serde_json::from_str(&json).unwrap();
693        assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
694    }
695
696    #[test]
697    fn agent_output_debug_does_not_panic() {
698        let output = AgentOutput {
699            value: json!(null),
700            session_id: None,
701            cost_usd: None,
702            input_tokens: None,
703            output_tokens: None,
704            model: None,
705            duration_ms: 0,
706            debug_messages: None,
707        };
708        let debug_str = format!("{:?}", output);
709        assert!(!debug_str.is_empty());
710    }
711
712    #[test]
713    fn allow_tool_transitions_to_with_tools() {
714        let config = AgentConfig::new("test").allow_tool("Read");
715        assert_eq!(config.allowed_tools, vec!["Read"]);
716
717        // Can add more tools
718        let config = config.allow_tool("Write");
719        assert_eq!(config.allowed_tools, vec!["Read", "Write"]);
720    }
721
722    #[test]
723    fn output_schema_raw_transitions_to_with_schema() {
724        let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
725        assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
726    }
727
728    #[test]
729    fn with_tools_converts_to_base_type() {
730        let typed = AgentConfig::new("test").allow_tool("Read");
731        let base: AgentConfig = typed.into();
732        assert_eq!(base.allowed_tools, vec!["Read"]);
733    }
734
735    #[test]
736    fn with_schema_converts_to_base_type() {
737        let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
738        let base: AgentConfig = typed.into();
739        assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
740    }
741
742    #[test]
743    fn serde_roundtrip_ignores_marker() {
744        let config = AgentConfig::new("test").allow_tool("Read");
745        let json = serde_json::to_string(&config).unwrap();
746        assert!(!json.contains("marker"));
747
748        let back: AgentConfig = serde_json::from_str(&json).unwrap();
749        assert_eq!(back.allowed_tools, vec!["Read"]);
750    }
751}