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    /// When `true`, pass `--strict-mcp-config` to the Claude CLI so it only
126    /// loads MCP servers from [`mcp_config`](Self::mcp_config) and ignores
127    /// any global/user MCP configuration (e.g. `~/.claude.json`).
128    ///
129    /// Useful to prevent global MCP servers from leaking tools into steps
130    /// that request `structured_output`, which triggers the Claude CLI bug
131    /// where `--json-schema` combined with any active tool returns
132    /// `structured_output: null`. See
133    /// <https://github.com/anthropics/claude-code/issues/18536>.
134    ///
135    /// Combine with `mcp_config` set to a file containing
136    /// `{"mcpServers":{}}` to disable every MCP server for the invocation.
137    #[serde(default)]
138    pub strict_mcp_config: bool,
139
140    /// Permission mode controlling how the agent handles tool-use approvals.
141    #[serde(default)]
142    pub permission_mode: PermissionMode,
143
144    /// Optional JSON Schema string. When set, the provider should request
145    /// structured (typed) output from the model.
146    #[serde(alias = "output_schema")]
147    pub json_schema: Option<String>,
148
149    /// Optional session ID to resume a previous conversation.
150    ///
151    /// When set, the provider should continue the conversation from the
152    /// specified session rather than starting a new one.
153    pub resume_session_id: Option<String>,
154
155    /// Enable verbose/debug mode to capture the full conversation trace.
156    ///
157    /// When `true`, the provider uses streaming output (`stream-json`) to
158    /// record every assistant message and tool call. The resulting
159    /// [`AgentOutput::debug_messages`] field will contain the conversation
160    /// trace for inspection.
161    #[serde(default)]
162    pub verbose: bool,
163
164    /// Zero-sized typestate marker (not serialized).
165    #[serde(skip)]
166    pub(crate) _marker: PhantomData<(Tools, Schema)>,
167}
168
169fn default_model() -> String {
170    Model::SONNET.to_string()
171}
172
173// ── Constructor (base type only) ───────────────────────────────────
174
175impl AgentConfig {
176    /// Create an `AgentConfig` with required fields and defaults for the rest.
177    pub fn new(prompt: &str) -> Self {
178        Self {
179            system_prompt: None,
180            prompt: prompt.to_string(),
181            model: Model::SONNET.to_string(),
182            allowed_tools: Vec::new(),
183            max_turns: None,
184            max_budget_usd: None,
185            working_dir: None,
186            mcp_config: None,
187            strict_mcp_config: false,
188            permission_mode: PermissionMode::Default,
189            json_schema: None,
190            resume_session_id: None,
191            verbose: false,
192            _marker: PhantomData,
193        }
194    }
195}
196
197// ── Methods available on ALL typestate variants ────────────────────
198
199impl<Tools, Schema> AgentConfig<Tools, Schema> {
200    /// Set the system prompt.
201    pub fn system_prompt(mut self, prompt: &str) -> Self {
202        self.system_prompt = Some(prompt.to_string());
203        self
204    }
205
206    /// Set the model name.
207    pub fn model(mut self, model: &str) -> Self {
208        self.model = model.to_string();
209        self
210    }
211
212    /// Set the maximum budget in USD.
213    pub fn max_budget_usd(mut self, budget: f64) -> Self {
214        self.max_budget_usd = Some(budget);
215        self
216    }
217
218    /// Set the maximum number of turns.
219    pub fn max_turns(mut self, turns: u32) -> Self {
220        self.max_turns = Some(turns);
221        self
222    }
223
224    /// Set the working directory.
225    pub fn working_dir(mut self, dir: &str) -> Self {
226        self.working_dir = Some(dir.to_string());
227        self
228    }
229
230    /// Set the permission mode.
231    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
232        self.permission_mode = mode;
233        self
234    }
235
236    /// Enable verbose/debug mode.
237    pub fn verbose(mut self, enabled: bool) -> Self {
238        self.verbose = enabled;
239        self
240    }
241
242    /// Set the MCP server configuration file path.
243    pub fn mcp_config(mut self, config: &str) -> Self {
244        self.mcp_config = Some(config.to_string());
245        self
246    }
247
248    /// Enable strict MCP config mode.
249    ///
250    /// When `true`, the Claude CLI is invoked with `--strict-mcp-config`,
251    /// which disables loading of any MCP server defined outside the
252    /// [`mcp_config`](Self::mcp_config) file (the global `~/.claude.json`
253    /// and user-level configs are ignored).
254    ///
255    /// This is the recommended way to prevent global MCP servers from
256    /// silently injecting tools into a structured-output step and
257    /// triggering the Claude CLI bug that returns `structured_output: null`
258    /// whenever any tool is active. See
259    /// <https://github.com/anthropics/claude-code/issues/18536>.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use ironflow_core::provider::AgentConfig;
265    /// use schemars::JsonSchema;
266    ///
267    /// #[derive(serde::Deserialize, JsonSchema)]
268    /// struct Out { ok: bool }
269    ///
270    /// // Isolate the step from any global MCP server so structured output works.
271    /// let config = AgentConfig::new("classify this")
272    ///     .strict_mcp_config(true)
273    ///     .mcp_config(r#"{"mcpServers":{}}"#)
274    ///     .output::<Out>();
275    /// ```
276    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
277        self.strict_mcp_config = strict;
278        self
279    }
280
281    /// Set a session ID to resume a previous conversation.
282    pub fn resume(mut self, session_id: &str) -> Self {
283        self.resume_session_id = Some(session_id.to_string());
284        self
285    }
286
287    /// Convert to a different typestate by moving all fields.
288    ///
289    /// Safe because the marker is a zero-sized [`PhantomData`] -- no
290    /// runtime data changes.
291    fn change_state<T2, S2>(self) -> AgentConfig<T2, S2> {
292        AgentConfig {
293            system_prompt: self.system_prompt,
294            prompt: self.prompt,
295            model: self.model,
296            allowed_tools: self.allowed_tools,
297            max_turns: self.max_turns,
298            max_budget_usd: self.max_budget_usd,
299            working_dir: self.working_dir,
300            mcp_config: self.mcp_config,
301            strict_mcp_config: self.strict_mcp_config,
302            permission_mode: self.permission_mode,
303            json_schema: self.json_schema,
304            resume_session_id: self.resume_session_id,
305            verbose: self.verbose,
306            _marker: PhantomData,
307        }
308    }
309}
310
311// ── allow_tool: only when no schema is set ─────────────────────────
312
313impl<Tools> AgentConfig<Tools, NoSchema> {
314    /// Add an allowed tool.
315    ///
316    /// Can be called multiple times to allow several tools. Returns an
317    /// [`AgentConfig<WithTools, NoSchema>`], which **cannot** call
318    /// [`output`](AgentConfig::output) or [`output_schema_raw`](AgentConfig::output_schema_raw).
319    ///
320    /// This restriction exists because Claude CLI has a
321    /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
322    /// where `--json-schema` combined with `--allowedTools` always returns
323    /// `structured_output: null`.
324    ///
325    /// **Workaround**: use two sequential agent steps -- one with tools to
326    /// gather data, then one with `.output::<T>()` to structure the result.
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// use ironflow_core::provider::AgentConfig;
332    ///
333    /// let config = AgentConfig::new("search the web")
334    ///     .allow_tool("WebSearch")
335    ///     .allow_tool("WebFetch");
336    /// ```
337    ///
338    /// ```compile_fail
339    /// use ironflow_core::provider::AgentConfig;
340    /// // ERROR: cannot set structured output after adding tools
341    /// let _ = AgentConfig::new("x")
342    ///     .allow_tool("Read")
343    ///     .output_schema_raw(r#"{"type":"object"}"#);
344    /// ```
345    pub fn allow_tool(mut self, tool: &str) -> AgentConfig<WithTools, NoSchema> {
346        self.allowed_tools.push(tool.to_string());
347        self.change_state()
348    }
349}
350
351// ── output: only when no tools are set ─────────────────────────────
352
353impl<Schema> AgentConfig<NoTools, Schema> {
354    /// Set structured output from a Rust type implementing [`JsonSchema`].
355    ///
356    /// The schema is serialized once at build time. When set, the provider
357    /// will request typed output conforming to this schema.
358    ///
359    /// **Important:** structured output requires `max_turns >= 2`.
360    ///
361    /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
362    /// call [`allow_tool`](AgentConfig::allow_tool).
363    ///
364    /// This restriction exists because Claude CLI has a
365    /// [known bug](https://github.com/anthropics/claude-code/issues/18536)
366    /// where `--json-schema` combined with `--allowedTools` always returns
367    /// `structured_output: null`.
368    ///
369    /// **Workaround**: use two sequential agent steps -- one with tools to
370    /// gather data, then one with `.output::<T>()` to structure the result.
371    ///
372    /// # Known limitations of Claude CLI structured output
373    ///
374    /// The Claude CLI does not guarantee strict schema conformance for
375    /// structured output. The following upstream bugs affect the behavior:
376    ///
377    /// - **Schema flattening** ([anthropics/claude-agent-sdk-python#502]):
378    ///   a schema like `{"type":"object","properties":{"items":{"type":"array",...}}}`
379    ///   may return a bare array instead of the wrapper object. The CLI
380    ///   non-deterministically flattens schemas with a single array field.
381    /// - **Non-deterministic wrapping** ([anthropics/claude-agent-sdk-python#374]):
382    ///   the same prompt can produce differently wrapped output across runs.
383    /// - **No conformance guarantee** ([anthropics/claude-code#9058]):
384    ///   the CLI does not validate output against the provided JSON schema.
385    ///
386    /// Because of these bugs, ironflow's provider layer applies multiple
387    /// fallback strategies when extracting the structured value (see
388    /// [`extract_structured_value`](crate::providers::claude::common::extract_structured_value)).
389    ///
390    /// [anthropics/claude-agent-sdk-python#502]: https://github.com/anthropics/claude-agent-sdk-python/issues/502
391    /// [anthropics/claude-agent-sdk-python#374]: https://github.com/anthropics/claude-agent-sdk-python/issues/374
392    /// [anthropics/claude-code#9058]: https://github.com/anthropics/claude-code/issues/9058
393    ///
394    /// # Examples
395    ///
396    /// ```
397    /// use ironflow_core::provider::AgentConfig;
398    /// use schemars::JsonSchema;
399    ///
400    /// #[derive(serde::Deserialize, JsonSchema)]
401    /// struct Labels { labels: Vec<String> }
402    ///
403    /// let config = AgentConfig::new("classify this text")
404    ///     .output::<Labels>();
405    /// ```
406    ///
407    /// ```compile_fail
408    /// use ironflow_core::provider::AgentConfig;
409    /// use schemars::JsonSchema;
410    /// #[derive(serde::Deserialize, JsonSchema)]
411    /// struct Out { x: i32 }
412    /// // ERROR: cannot add tools after setting structured output
413    /// let _ = AgentConfig::new("x").output::<Out>().allow_tool("Read");
414    /// ```
415    /// # Panics
416    ///
417    /// Panics if the schema generated by `schemars` cannot be serialized
418    /// to JSON. This indicates a bug in the type's `JsonSchema` derive,
419    /// not a recoverable runtime error.
420    pub fn output<T: JsonSchema>(mut self) -> AgentConfig<NoTools, WithSchema> {
421        let schema = schemars::schema_for!(T);
422        let serialized = serde_json::to_string(&schema).unwrap_or_else(|e| {
423            panic!(
424                "failed to serialize JSON schema for {}: {e}",
425                std::any::type_name::<T>()
426            )
427        });
428        self.json_schema = Some(serialized);
429        self.change_state()
430    }
431
432    /// Set structured output from a pre-serialized JSON Schema string.
433    ///
434    /// Returns an [`AgentConfig<NoTools, WithSchema>`], which **cannot**
435    /// call [`allow_tool`](AgentConfig::allow_tool). See [`output`](Self::output)
436    /// for the rationale and workaround.
437    pub fn output_schema_raw(mut self, schema: &str) -> AgentConfig<NoTools, WithSchema> {
438        self.json_schema = Some(schema.to_string());
439        self.change_state()
440    }
441}
442
443// ── From conversions to base type ──────────────────────────────────
444
445impl From<AgentConfig<WithTools, NoSchema>> for AgentConfig {
446    fn from(config: AgentConfig<WithTools, NoSchema>) -> Self {
447        config.change_state()
448    }
449}
450
451impl From<AgentConfig<NoTools, WithSchema>> for AgentConfig {
452    fn from(config: AgentConfig<NoTools, WithSchema>) -> Self {
453        config.change_state()
454    }
455}
456
457// ── AgentOutput ────────────────────────────────────────────────────
458
459/// Raw output returned by an [`AgentProvider`] after a successful invocation.
460///
461/// Carries the agent's response value together with usage and billing metadata.
462#[derive(Clone, Debug, Serialize, Deserialize)]
463#[non_exhaustive]
464pub struct AgentOutput {
465    /// The agent's response. A plain [`Value::String`] for text mode, or an
466    /// arbitrary JSON value when a JSON schema was requested.
467    pub value: Value,
468
469    /// Provider-assigned session identifier, useful for resuming conversations.
470    pub session_id: Option<String>,
471
472    /// Total cost in USD for this invocation, if reported by the provider.
473    pub cost_usd: Option<f64>,
474
475    /// Number of input tokens consumed, if reported.
476    pub input_tokens: Option<u64>,
477
478    /// Number of output tokens generated, if reported.
479    pub output_tokens: Option<u64>,
480
481    /// The concrete model identifier used (e.g. `"claude-sonnet-4-20250514"`).
482    pub model: Option<String>,
483
484    /// Wall-clock duration of the invocation in milliseconds.
485    pub duration_ms: u64,
486
487    /// Conversation trace captured when [`AgentConfig::verbose`] is `true`.
488    ///
489    /// Contains every assistant message and tool call made during the
490    /// invocation, in chronological order. `None` when verbose mode is off.
491    pub debug_messages: Option<Vec<DebugMessage>>,
492}
493
494/// A single assistant turn captured during a verbose invocation.
495///
496/// Each `DebugMessage` represents one assistant response, which may contain
497/// free-form text, tool calls, or both.
498///
499/// # Examples
500///
501/// ```no_run
502/// use ironflow_core::prelude::*;
503///
504/// # async fn example() -> Result<(), OperationError> {
505/// let provider = ClaudeCodeProvider::new();
506/// let result = Agent::new()
507///     .prompt("List files in src/")
508///     .verbose()
509///     .run(&provider)
510///     .await?;
511///
512/// if let Some(messages) = result.debug_messages() {
513///     for msg in messages {
514///         println!("{msg}");
515///     }
516/// }
517/// # Ok(())
518/// # }
519/// ```
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[non_exhaustive]
522pub struct DebugMessage {
523    /// Free-form text produced by the assistant in this turn, if any.
524    pub text: Option<String>,
525
526    /// Tool calls made by the assistant in this turn.
527    pub tool_calls: Vec<DebugToolCall>,
528
529    /// The model's stop reason for this turn (e.g. `"end_turn"`, `"tool_use"`).
530    pub stop_reason: Option<String>,
531}
532
533impl fmt::Display for DebugMessage {
534    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535        if let Some(ref text) = self.text {
536            writeln!(f, "[assistant] {text}")?;
537        }
538        for tc in &self.tool_calls {
539            write!(f, "{tc}")?;
540        }
541        Ok(())
542    }
543}
544
545/// A single tool call captured during a verbose invocation.
546///
547/// Records the tool name and its input arguments as a raw JSON value.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549#[non_exhaustive]
550pub struct DebugToolCall {
551    /// Name of the tool invoked (e.g. `"Read"`, `"Bash"`, `"Grep"`).
552    pub name: String,
553
554    /// Input arguments passed to the tool, as raw JSON.
555    pub input: Value,
556}
557
558impl fmt::Display for DebugToolCall {
559    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560        writeln!(f, "  [tool_use] {} -> {}", self.name, self.input)
561    }
562}
563
564impl AgentOutput {
565    /// Create an `AgentOutput` with the given value and sensible defaults.
566    pub fn new(value: Value) -> Self {
567        Self {
568            value,
569            session_id: None,
570            cost_usd: None,
571            input_tokens: None,
572            output_tokens: None,
573            model: None,
574            duration_ms: 0,
575            debug_messages: None,
576        }
577    }
578}
579
580// ── Provider trait ─────────────────────────────────────────────────
581
582/// Trait for AI agent backends.
583///
584/// Implement this trait to provide a custom AI backend for [`Agent`](crate::operations::agent::Agent).
585/// The only required method is [`invoke`](AgentProvider::invoke), which takes an
586/// [`AgentConfig`] and returns an [`AgentOutput`] (or an [`AgentError`]).
587///
588/// # Examples
589///
590/// ```no_run
591/// use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
592///
593/// struct MyProvider;
594///
595/// impl AgentProvider for MyProvider {
596///     fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
597///         Box::pin(async move {
598///             // Call your custom backend here...
599///             todo!()
600///         })
601///     }
602/// }
603/// ```
604pub trait AgentProvider: Send + Sync {
605    /// Execute a single agent invocation with the given configuration.
606    ///
607    /// # Errors
608    ///
609    /// Returns [`AgentError`] if the underlying backend process fails,
610    /// times out, or produces output that does not match the requested schema.
611    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a>;
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use serde_json::json;
618
619    fn full_config() -> AgentConfig {
620        AgentConfig {
621            system_prompt: Some("you are helpful".to_string()),
622            prompt: "do stuff".to_string(),
623            model: Model::OPUS.to_string(),
624            allowed_tools: vec!["Read".to_string(), "Write".to_string()],
625            max_turns: Some(10),
626            max_budget_usd: Some(2.5),
627            working_dir: Some("/tmp".to_string()),
628            mcp_config: Some("{}".to_string()),
629            strict_mcp_config: true,
630            permission_mode: PermissionMode::Auto,
631            json_schema: Some(r#"{"type":"object"}"#.to_string()),
632            resume_session_id: None,
633            verbose: false,
634            _marker: PhantomData,
635        }
636    }
637
638    #[test]
639    fn agent_config_serialize_deserialize_roundtrip() {
640        let config = full_config();
641        let json = serde_json::to_string(&config).unwrap();
642        let back: AgentConfig = serde_json::from_str(&json).unwrap();
643
644        assert_eq!(back.system_prompt, Some("you are helpful".to_string()));
645        assert_eq!(back.prompt, "do stuff");
646        assert_eq!(back.allowed_tools, vec!["Read", "Write"]);
647        assert_eq!(back.max_turns, Some(10));
648        assert_eq!(back.max_budget_usd, Some(2.5));
649        assert_eq!(back.working_dir, Some("/tmp".to_string()));
650        assert_eq!(back.mcp_config, Some("{}".to_string()));
651        assert_eq!(back.json_schema, Some(r#"{"type":"object"}"#.to_string()));
652    }
653
654    #[test]
655    fn agent_config_with_all_optional_fields_none() {
656        let config: AgentConfig = AgentConfig {
657            system_prompt: None,
658            prompt: "hello".to_string(),
659            model: Model::HAIKU.to_string(),
660            allowed_tools: vec![],
661            max_turns: None,
662            max_budget_usd: None,
663            working_dir: None,
664            mcp_config: None,
665            strict_mcp_config: false,
666            permission_mode: PermissionMode::Default,
667            json_schema: None,
668            resume_session_id: None,
669            verbose: false,
670            _marker: PhantomData,
671        };
672        let json = serde_json::to_string(&config).unwrap();
673        let back: AgentConfig = serde_json::from_str(&json).unwrap();
674
675        assert_eq!(back.system_prompt, None);
676        assert_eq!(back.prompt, "hello");
677        assert!(back.allowed_tools.is_empty());
678        assert_eq!(back.max_turns, None);
679        assert_eq!(back.max_budget_usd, None);
680        assert_eq!(back.working_dir, None);
681        assert_eq!(back.mcp_config, None);
682        assert_eq!(back.json_schema, None);
683    }
684
685    #[test]
686    fn agent_output_serialize_deserialize_roundtrip() {
687        let output = AgentOutput {
688            value: json!({"key": "value"}),
689            session_id: Some("sess-abc".to_string()),
690            cost_usd: Some(0.01),
691            input_tokens: Some(500),
692            output_tokens: Some(200),
693            model: Some("claude-sonnet".to_string()),
694            duration_ms: 3000,
695            debug_messages: None,
696        };
697        let json = serde_json::to_string(&output).unwrap();
698        let back: AgentOutput = serde_json::from_str(&json).unwrap();
699
700        assert_eq!(back.value, json!({"key": "value"}));
701        assert_eq!(back.session_id, Some("sess-abc".to_string()));
702        assert_eq!(back.cost_usd, Some(0.01));
703        assert_eq!(back.input_tokens, Some(500));
704        assert_eq!(back.output_tokens, Some(200));
705        assert_eq!(back.model, Some("claude-sonnet".to_string()));
706        assert_eq!(back.duration_ms, 3000);
707    }
708
709    #[test]
710    fn agent_config_new_has_correct_defaults() {
711        let config = AgentConfig::new("test prompt");
712        assert_eq!(config.prompt, "test prompt");
713        assert_eq!(config.system_prompt, None);
714        assert_eq!(config.model, Model::SONNET);
715        assert!(config.allowed_tools.is_empty());
716        assert_eq!(config.max_turns, None);
717        assert_eq!(config.max_budget_usd, None);
718        assert_eq!(config.working_dir, None);
719        assert_eq!(config.mcp_config, None);
720        assert!(matches!(config.permission_mode, PermissionMode::Default));
721        assert_eq!(config.json_schema, None);
722        assert_eq!(config.resume_session_id, None);
723        assert!(!config.verbose);
724    }
725
726    #[test]
727    fn agent_output_new_has_correct_defaults() {
728        let output = AgentOutput::new(json!("test"));
729        assert_eq!(output.value, json!("test"));
730        assert_eq!(output.session_id, None);
731        assert_eq!(output.cost_usd, None);
732        assert_eq!(output.input_tokens, None);
733        assert_eq!(output.output_tokens, None);
734        assert_eq!(output.model, None);
735        assert_eq!(output.duration_ms, 0);
736        assert!(output.debug_messages.is_none());
737    }
738
739    #[test]
740    fn agent_config_resume_session_roundtrip() {
741        let mut config = AgentConfig::new("test");
742        config.resume_session_id = Some("sess-xyz".to_string());
743        let json = serde_json::to_string(&config).unwrap();
744        let back: AgentConfig = serde_json::from_str(&json).unwrap();
745        assert_eq!(back.resume_session_id, Some("sess-xyz".to_string()));
746    }
747
748    #[test]
749    fn agent_output_debug_does_not_panic() {
750        let output = AgentOutput {
751            value: json!(null),
752            session_id: None,
753            cost_usd: None,
754            input_tokens: None,
755            output_tokens: None,
756            model: None,
757            duration_ms: 0,
758            debug_messages: None,
759        };
760        let debug_str = format!("{:?}", output);
761        assert!(!debug_str.is_empty());
762    }
763
764    #[test]
765    fn allow_tool_transitions_to_with_tools() {
766        let config = AgentConfig::new("test").allow_tool("Read");
767        assert_eq!(config.allowed_tools, vec!["Read"]);
768
769        // Can add more tools
770        let config = config.allow_tool("Write");
771        assert_eq!(config.allowed_tools, vec!["Read", "Write"]);
772    }
773
774    #[test]
775    fn output_schema_raw_transitions_to_with_schema() {
776        let config = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
777        assert_eq!(config.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
778    }
779
780    #[test]
781    fn with_tools_converts_to_base_type() {
782        let typed = AgentConfig::new("test").allow_tool("Read");
783        let base: AgentConfig = typed.into();
784        assert_eq!(base.allowed_tools, vec!["Read"]);
785    }
786
787    #[test]
788    fn with_schema_converts_to_base_type() {
789        let typed = AgentConfig::new("test").output_schema_raw(r#"{"type":"object"}"#);
790        let base: AgentConfig = typed.into();
791        assert_eq!(base.json_schema.as_deref(), Some(r#"{"type":"object"}"#));
792    }
793
794    #[test]
795    fn serde_roundtrip_ignores_marker() {
796        let config = AgentConfig::new("test").allow_tool("Read");
797        let json = serde_json::to_string(&config).unwrap();
798        assert!(!json.contains("marker"));
799
800        let back: AgentConfig = serde_json::from_str(&json).unwrap();
801        assert_eq!(back.allowed_tools, vec!["Read"]);
802    }
803}