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}