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}