Skip to main content

swink_agent/
config.rs

1//! Serializable agent configuration.
2//!
3//! [`AgentConfig`] captures the subset of [`AgentOptions`](crate::AgentOptions)
4//! that can be round-tripped through serde. Trait objects (tools, transformers,
5//! policies, callbacks) are represented by name so they can be re-registered
6//! after deserialization.
7
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::stream::StreamTransport;
13use crate::tool::ApprovalMode;
14use crate::types::ModelSpec;
15
16// ─── RetryConfig ─────────────────────────────────────────────────────────────
17
18/// Serializable representation of [`DefaultRetryStrategy`](crate::DefaultRetryStrategy) parameters.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RetryConfig {
21    /// Maximum number of attempts (including the initial call).
22    pub max_attempts: u32,
23    /// Base delay in milliseconds before the first retry.
24    pub base_delay_ms: u64,
25    /// Maximum delay cap in milliseconds.
26    pub max_delay_ms: u64,
27    /// Exponential multiplier per attempt.
28    pub multiplier: f64,
29    /// Whether jitter is applied to delays.
30    pub jitter: bool,
31}
32
33impl Default for RetryConfig {
34    fn default() -> Self {
35        let default = crate::retry::DefaultRetryStrategy::default();
36        Self {
37            max_attempts: default.max_attempts,
38            base_delay_ms: default
39                .base_delay
40                .as_millis()
41                .try_into()
42                .unwrap_or(u64::MAX),
43            max_delay_ms: default.max_delay.as_millis().try_into().unwrap_or(u64::MAX),
44            multiplier: default.multiplier,
45            jitter: default.jitter,
46        }
47    }
48}
49
50impl From<&crate::retry::DefaultRetryStrategy> for RetryConfig {
51    fn from(s: &crate::retry::DefaultRetryStrategy) -> Self {
52        Self {
53            max_attempts: s.max_attempts,
54            base_delay_ms: s.base_delay.as_millis().try_into().unwrap_or(u64::MAX),
55            max_delay_ms: s.max_delay.as_millis().try_into().unwrap_or(u64::MAX),
56            multiplier: s.multiplier,
57            jitter: s.jitter,
58        }
59    }
60}
61
62impl RetryConfig {
63    /// Convert back to a [`DefaultRetryStrategy`](crate::DefaultRetryStrategy).
64    #[must_use]
65    pub const fn to_retry_strategy(&self) -> crate::retry::DefaultRetryStrategy {
66        crate::retry::DefaultRetryStrategy {
67            max_attempts: self.max_attempts,
68            base_delay: Duration::from_millis(self.base_delay_ms),
69            max_delay: Duration::from_millis(self.max_delay_ms),
70            multiplier: self.multiplier,
71            jitter: self.jitter,
72        }
73    }
74}
75
76// ─── StreamOptionsConfig ─────────────────────────────────────────────────────
77
78/// Serializable representation of [`StreamOptions`](crate::StreamOptions).
79///
80/// The `api_key` field is intentionally omitted — secrets should not be
81/// persisted in config snapshots.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct StreamOptionsConfig {
84    /// Sampling temperature.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub temperature: Option<f64>,
87    /// Output token limit.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub max_tokens: Option<u64>,
90    /// Provider-side session identifier.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub session_id: Option<String>,
93    /// Preferred transport protocol.
94    #[serde(default)]
95    pub transport: StreamTransport,
96}
97
98impl From<&crate::stream::StreamOptions> for StreamOptionsConfig {
99    fn from(opts: &crate::stream::StreamOptions) -> Self {
100        Self {
101            temperature: opts.temperature,
102            max_tokens: opts.max_tokens,
103            session_id: opts.session_id.clone(),
104            transport: opts.transport,
105        }
106    }
107}
108
109impl StreamOptionsConfig {
110    /// Convert back to [`StreamOptions`](crate::StreamOptions), leaving `api_key` as `None`.
111    #[must_use]
112    pub fn to_stream_options(&self) -> crate::stream::StreamOptions {
113        crate::stream::StreamOptions {
114            temperature: self.temperature,
115            max_tokens: self.max_tokens,
116            session_id: self.session_id.clone(),
117            api_key: None,
118            transport: self.transport,
119            cache_strategy: crate::stream::CacheStrategy::default(),
120            on_raw_payload: None,
121        }
122    }
123}
124
125// ─── SteeringMode / FollowUpMode serde wrappers ─────────────────────────────
126
127/// Serializable mirror of [`SteeringMode`](crate::SteeringMode).
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SteeringModeConfig {
131    All,
132    #[default]
133    OneAtATime,
134}
135
136impl From<crate::agent::SteeringMode> for SteeringModeConfig {
137    fn from(m: crate::agent::SteeringMode) -> Self {
138        match m {
139            crate::agent::SteeringMode::All => Self::All,
140            crate::agent::SteeringMode::OneAtATime => Self::OneAtATime,
141        }
142    }
143}
144
145impl From<SteeringModeConfig> for crate::agent::SteeringMode {
146    fn from(m: SteeringModeConfig) -> Self {
147        match m {
148            SteeringModeConfig::All => Self::All,
149            SteeringModeConfig::OneAtATime => Self::OneAtATime,
150        }
151    }
152}
153
154/// Serializable mirror of [`FollowUpMode`](crate::FollowUpMode).
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum FollowUpModeConfig {
158    All,
159    #[default]
160    OneAtATime,
161}
162
163impl From<crate::agent::FollowUpMode> for FollowUpModeConfig {
164    fn from(m: crate::agent::FollowUpMode) -> Self {
165        match m {
166            crate::agent::FollowUpMode::All => Self::All,
167            crate::agent::FollowUpMode::OneAtATime => Self::OneAtATime,
168        }
169    }
170}
171
172impl From<FollowUpModeConfig> for crate::agent::FollowUpMode {
173    fn from(m: FollowUpModeConfig) -> Self {
174        match m {
175            FollowUpModeConfig::All => Self::All,
176            FollowUpModeConfig::OneAtATime => Self::OneAtATime,
177        }
178    }
179}
180
181/// Serializable mirror of [`ApprovalMode`](crate::ApprovalMode).
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ApprovalModeConfig {
185    #[default]
186    Enabled,
187    Smart,
188    Bypassed,
189}
190
191// ─── CacheConfigData ────────────────────────────────────────────────────────
192
193/// Serializable representation of [`CacheConfig`](crate::context_cache::CacheConfig).
194///
195/// Duration is stored as milliseconds for serde-friendliness.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct CacheConfigData {
198    /// Time-to-live in milliseconds.
199    pub ttl_ms: u64,
200    /// Minimum token count for the cached prefix.
201    pub min_tokens: usize,
202    /// Number of turns between cache refreshes.
203    pub cache_intervals: usize,
204}
205
206impl From<&crate::context_cache::CacheConfig> for CacheConfigData {
207    fn from(c: &crate::context_cache::CacheConfig) -> Self {
208        Self {
209            ttl_ms: c.ttl.as_millis().try_into().unwrap_or(u64::MAX),
210            min_tokens: c.min_tokens,
211            cache_intervals: c.cache_intervals,
212        }
213    }
214}
215
216impl CacheConfigData {
217    /// Convert back to a [`CacheConfig`](crate::context_cache::CacheConfig).
218    #[must_use]
219    pub const fn to_cache_config(&self) -> crate::context_cache::CacheConfig {
220        crate::context_cache::CacheConfig::new(
221            std::time::Duration::from_millis(self.ttl_ms),
222            self.min_tokens,
223            self.cache_intervals,
224        )
225    }
226}
227
228impl From<ApprovalMode> for ApprovalModeConfig {
229    fn from(m: ApprovalMode) -> Self {
230        match m {
231            ApprovalMode::Enabled => Self::Enabled,
232            ApprovalMode::Smart => Self::Smart,
233            ApprovalMode::Bypassed => Self::Bypassed,
234        }
235    }
236}
237
238impl From<ApprovalModeConfig> for ApprovalMode {
239    fn from(m: ApprovalModeConfig) -> Self {
240        match m {
241            ApprovalModeConfig::Enabled => Self::Enabled,
242            ApprovalModeConfig::Smart => Self::Smart,
243            ApprovalModeConfig::Bypassed => Self::Bypassed,
244        }
245    }
246}
247
248// ─── AgentConfig ─────────────────────────────────────────────────────────────
249
250/// A fully serializable snapshot of agent configuration.
251///
252/// Captures the subset of [`AgentOptions`](crate::AgentOptions) fields that can
253/// survive a serde round-trip. Trait objects (tools, stream functions,
254/// transformers, policies, callbacks) **cannot** be serialized and must be
255/// re-registered by the consumer after deserialization.
256///
257/// # What round-trips faithfully
258///
259/// `system_prompt`, `model`, `retry`, `stream_options`, `steering_mode`,
260/// `follow_up_mode`, `structured_output_max_retries`, `approval_mode`,
261/// `plan_mode_addendum`, and `cache_config` are all restored by
262/// [`into_agent_options()`](Self::into_agent_options).
263///
264/// # What does NOT round-trip
265///
266/// - **`tool_names`** — stored for informational use only (e.g., re-registering
267///   tools by name). The consumer must supply the actual tool implementations.
268/// - **`extra`** — application-level metadata that has no corresponding
269///   `AgentOptions` field. Survives serde but is not fed back into the agent.
270/// - **Trait objects** — `stream_fn`, `convert_to_llm`, `transform_context`,
271///   `approve_tool`, policies, event forwarders, etc. must be re-attached.
272///
273/// # Example
274///
275/// ```ignore
276/// // Save
277/// let config = agent.options().to_config();
278/// let json = serde_json::to_string(&config)?;
279///
280/// // Restore
281/// let config: AgentConfig = serde_json::from_str(&json)?;
282/// let opts = AgentOptions::from_config(config, stream_fn, convert_to_llm)
283///     .with_tools(re_register_tools(&config.tool_names));
284/// ```
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct AgentConfig {
287    /// System prompt sent to the LLM.
288    pub system_prompt: String,
289
290    /// Model specification (provider, model ID, thinking level, etc.).
291    pub model: ModelSpec,
292
293    /// Names of registered tools (routing keys from [`AgentTool::name()`](crate::AgentTool::name)).
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub tool_names: Vec<String>,
296
297    /// Retry strategy parameters.
298    #[serde(default)]
299    pub retry: RetryConfig,
300
301    /// Per-call stream options (temperature, max tokens, transport).
302    #[serde(default)]
303    pub stream_options: StreamOptionsConfig,
304
305    /// Steering queue drain mode.
306    #[serde(default)]
307    pub steering_mode: SteeringModeConfig,
308
309    /// Follow-up queue drain mode.
310    #[serde(default)]
311    pub follow_up_mode: FollowUpModeConfig,
312
313    /// Max retries for structured output validation.
314    #[serde(default = "default_structured_output_max_retries")]
315    pub structured_output_max_retries: usize,
316
317    /// Approval mode for the tool gate.
318    #[serde(default)]
319    pub approval_mode: ApprovalModeConfig,
320
321    /// Optional plan mode addendum appended to the system prompt.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub plan_mode_addendum: Option<String>,
324
325    /// Optional context caching configuration.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub cache_config: Option<CacheConfigData>,
328
329    /// Arbitrary extension data for application-specific config.
330    ///
331    /// This field survives serialization but is **not** restored into
332    /// [`AgentOptions`](crate::AgentOptions) — it has no corresponding field
333    /// there. Use it to store application-level metadata alongside the config.
334    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
335    pub extra: serde_json::Value,
336}
337
338const fn default_structured_output_max_retries() -> usize {
339    3
340}
341
342impl AgentConfig {
343    /// Restore an [`AgentOptions`](crate::AgentOptions) builder from this config.
344    ///
345    /// The caller must supply the required non-serializable arguments
346    /// (`stream_fn` and `convert_to_llm`) and then re-attach any trait objects
347    /// (tools, transformers, policies) via the builder methods.
348    #[must_use]
349    pub fn into_agent_options(
350        self,
351        stream_fn: std::sync::Arc<dyn crate::stream::StreamFn>,
352        convert_to_llm: impl Fn(&crate::types::AgentMessage) -> Option<crate::types::LlmMessage>
353        + Send
354        + Sync
355        + 'static,
356    ) -> crate::agent::AgentOptions {
357        let mut opts = crate::agent::AgentOptions::new(
358            self.system_prompt,
359            self.model,
360            stream_fn,
361            convert_to_llm,
362        );
363
364        opts.retry_strategy = Box::new(self.retry.to_retry_strategy());
365        opts.stream_options = self.stream_options.to_stream_options();
366        opts.steering_mode = self.steering_mode.into();
367        opts.follow_up_mode = self.follow_up_mode.into();
368        opts.structured_output_max_retries = self.structured_output_max_retries;
369        opts.approval_mode = self.approval_mode.into();
370        opts.plan_mode_addendum = self.plan_mode_addendum;
371        opts.cache_config = self.cache_config.map(|c| c.to_cache_config());
372
373        // Clear the default transform_context — the caller may want to re-attach
374        // their own, and `from_config` should not silently override.
375        opts.transform_context = None;
376
377        opts
378    }
379}
380
381// ─── AgentOptions::to_config / from_config ───────────────────────────────────
382
383impl crate::agent::AgentOptions {
384    /// Extract a serializable [`AgentConfig`] from these options.
385    ///
386    /// Tool implementations are represented by name only. Trait objects
387    /// (transformers, policies, callbacks) are omitted — their presence must
388    /// be restored by the consumer after deserialization.
389    #[must_use]
390    pub fn to_config(&self) -> AgentConfig {
391        let tool_names: Vec<String> = self.tools.iter().map(|t| t.name().to_string()).collect();
392
393        // Attempt to extract retry params from a DefaultRetryStrategy. If the
394        // caller used a custom RetryStrategy we fall back to defaults.
395        let retry = downcast_retry_config(&*self.retry_strategy);
396
397        AgentConfig {
398            system_prompt: self.system_prompt.clone(),
399            model: self.model.clone(),
400            tool_names,
401            retry,
402            stream_options: StreamOptionsConfig::from(&self.stream_options),
403            steering_mode: self.steering_mode.into(),
404            follow_up_mode: self.follow_up_mode.into(),
405            structured_output_max_retries: self.structured_output_max_retries,
406            approval_mode: self.approval_mode.into(),
407            plan_mode_addendum: self.plan_mode_addendum.clone(),
408            cache_config: self.cache_config.as_ref().map(CacheConfigData::from),
409            extra: serde_json::Value::Null,
410        }
411    }
412
413    /// Construct `AgentOptions` from a deserialized [`AgentConfig`].
414    ///
415    /// Equivalent to [`AgentConfig::into_agent_options`] — provided here for
416    /// discoverability.
417    #[must_use]
418    pub fn from_config(
419        config: AgentConfig,
420        stream_fn: std::sync::Arc<dyn crate::stream::StreamFn>,
421        convert_to_llm: impl Fn(&crate::types::AgentMessage) -> Option<crate::types::LlmMessage>
422        + Send
423        + Sync
424        + 'static,
425    ) -> Self {
426        config.into_agent_options(stream_fn, convert_to_llm)
427    }
428}
429
430/// Try to downcast the retry strategy to `DefaultRetryStrategy` and extract its
431/// parameters. Falls back to `RetryConfig::default()` for custom strategies.
432fn downcast_retry_config(strategy: &dyn crate::retry::RetryStrategy) -> RetryConfig {
433    strategy
434        .as_any()
435        .downcast_ref::<crate::retry::DefaultRetryStrategy>()
436        .map_or_else(RetryConfig::default, RetryConfig::from)
437}
438
439// ─── Send + Sync assertions ─────────────────────────────────────────────────
440
441const _: () = {
442    const fn assert_send_sync<T: Send + Sync>() {}
443    assert_send_sync::<AgentConfig>();
444    assert_send_sync::<RetryConfig>();
445    assert_send_sync::<StreamOptionsConfig>();
446    assert_send_sync::<SteeringModeConfig>();
447    assert_send_sync::<FollowUpModeConfig>();
448    assert_send_sync::<ApprovalModeConfig>();
449};
450
451// ─── Tests ───────────────────────────────────────────────────────────────────
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::types::ThinkingLevel;
457
458    #[test]
459    fn retry_config_roundtrip() {
460        let config = RetryConfig {
461            max_attempts: 5,
462            base_delay_ms: 2000,
463            max_delay_ms: 120_000,
464            multiplier: 3.0,
465            jitter: false,
466        };
467        let json = serde_json::to_string(&config).unwrap();
468        let restored: RetryConfig = serde_json::from_str(&json).unwrap();
469        assert_eq!(restored.max_attempts, 5);
470        assert_eq!(restored.base_delay_ms, 2000);
471        assert_eq!(restored.max_delay_ms, 120_000);
472        assert!((restored.multiplier - 3.0).abs() < f64::EPSILON);
473        assert!(!restored.jitter);
474    }
475
476    #[test]
477    fn retry_config_to_strategy_and_back() {
478        let config = RetryConfig {
479            max_attempts: 4,
480            base_delay_ms: 500,
481            max_delay_ms: 30_000,
482            multiplier: 1.5,
483            jitter: true,
484        };
485        let strategy = config.to_retry_strategy();
486        assert_eq!(strategy.max_attempts, 4);
487        assert_eq!(strategy.base_delay, Duration::from_millis(500));
488        assert_eq!(strategy.max_delay, Duration::from_millis(30_000));
489        assert!((strategy.multiplier - 1.5).abs() < f64::EPSILON);
490        assert!(strategy.jitter);
491
492        let back = RetryConfig::from(&strategy);
493        assert_eq!(back.max_attempts, 4);
494        assert_eq!(back.base_delay_ms, 500);
495    }
496
497    #[test]
498    fn stream_options_config_roundtrip() {
499        let config = StreamOptionsConfig {
500            temperature: Some(0.7),
501            max_tokens: Some(4096),
502            session_id: Some("sess-123".into()),
503            transport: StreamTransport::Sse,
504        };
505        let json = serde_json::to_string(&config).unwrap();
506        let restored: StreamOptionsConfig = serde_json::from_str(&json).unwrap();
507        assert_eq!(restored.temperature, Some(0.7));
508        assert_eq!(restored.max_tokens, Some(4096));
509        assert_eq!(restored.session_id.as_deref(), Some("sess-123"));
510    }
511
512    #[test]
513    fn stream_options_config_omits_api_key() {
514        let opts = crate::stream::StreamOptions {
515            temperature: Some(0.5),
516            max_tokens: None,
517            session_id: None,
518            api_key: Some("secret-key".into()),
519            transport: StreamTransport::Sse,
520            cache_strategy: crate::stream::CacheStrategy::default(),
521            on_raw_payload: None,
522        };
523        let config = StreamOptionsConfig::from(&opts);
524        let json = serde_json::to_string(&config).unwrap();
525        assert!(!json.contains("secret-key"));
526
527        let restored_opts = config.to_stream_options();
528        assert!(restored_opts.api_key.is_none());
529        assert_eq!(restored_opts.temperature, Some(0.5));
530    }
531
532    #[test]
533    fn agent_config_serde_roundtrip() {
534        let config = AgentConfig {
535            system_prompt: "Be helpful.".into(),
536            model: ModelSpec::new("anthropic", "claude-sonnet")
537                .with_thinking_level(ThinkingLevel::Medium),
538            tool_names: vec!["bash".into(), "read_file".into()],
539            retry: RetryConfig {
540                max_attempts: 5,
541                base_delay_ms: 1000,
542                max_delay_ms: 60_000,
543                multiplier: 2.0,
544                jitter: true,
545            },
546            stream_options: StreamOptionsConfig {
547                temperature: Some(0.7),
548                max_tokens: Some(8192),
549                session_id: None,
550                transport: StreamTransport::Sse,
551            },
552            steering_mode: SteeringModeConfig::OneAtATime,
553            follow_up_mode: FollowUpModeConfig::All,
554            structured_output_max_retries: 5,
555            approval_mode: ApprovalModeConfig::Smart,
556            plan_mode_addendum: Some("Custom plan instructions.".into()),
557            cache_config: Some(CacheConfigData {
558                ttl_ms: 300_000,
559                min_tokens: 1024,
560                cache_intervals: 4,
561            }),
562            extra: serde_json::json!({"custom_key": "custom_value"}),
563        };
564
565        let json = serde_json::to_string_pretty(&config).unwrap();
566        let restored: AgentConfig = serde_json::from_str(&json).unwrap();
567
568        assert_eq!(restored.system_prompt, "Be helpful.");
569        assert_eq!(restored.model.provider, "anthropic");
570        assert_eq!(restored.model.model_id, "claude-sonnet");
571        assert_eq!(restored.model.thinking_level, ThinkingLevel::Medium);
572        assert_eq!(restored.tool_names, vec!["bash", "read_file"]);
573        assert_eq!(restored.retry.max_attempts, 5);
574        assert_eq!(restored.stream_options.temperature, Some(0.7));
575        assert_eq!(restored.stream_options.max_tokens, Some(8192));
576        assert_eq!(restored.steering_mode, SteeringModeConfig::OneAtATime);
577        assert_eq!(restored.follow_up_mode, FollowUpModeConfig::All);
578        assert_eq!(restored.structured_output_max_retries, 5);
579        assert_eq!(restored.approval_mode, ApprovalModeConfig::Smart);
580        assert_eq!(
581            restored.plan_mode_addendum.as_deref(),
582            Some("Custom plan instructions.")
583        );
584        let cc = restored.cache_config.unwrap();
585        assert_eq!(cc.ttl_ms, 300_000);
586        assert_eq!(cc.min_tokens, 1024);
587        assert_eq!(cc.cache_intervals, 4);
588        assert_eq!(restored.extra["custom_key"], "custom_value");
589    }
590
591    #[test]
592    fn agent_config_minimal_json_deserializes() {
593        // Only required fields; everything else falls back to defaults.
594        let json = r#"{
595            "system_prompt": "Hello",
596            "model": {
597                "provider": "openai",
598                "model_id": "gpt-4",
599                "thinking_level": "off"
600            }
601        }"#;
602
603        let config: AgentConfig = serde_json::from_str(json).unwrap();
604        assert_eq!(config.system_prompt, "Hello");
605        assert_eq!(config.model.provider, "openai");
606        assert!(config.tool_names.is_empty());
607        assert_eq!(config.retry.max_attempts, 3); // default
608        assert_eq!(config.structured_output_max_retries, 3); // default
609    }
610
611    #[test]
612    fn old_json_with_removed_fields_still_deserializes() {
613        // Configs saved before these fields were removed should still load.
614        let json = r#"{
615            "system_prompt": "Hello",
616            "model": { "provider": "openai", "model_id": "gpt-4", "thinking_level": "off" },
617            "available_models": [{ "provider": "openai", "model_id": "gpt-4o", "thinking_level": "off" }],
618            "fallback_models": [{ "provider": "openai", "model_id": "gpt-4o-mini", "thinking_level": "off" }],
619            "budget_guard": { "max_cost": 10.0, "max_tokens": 100000 }
620        }"#;
621        let config: AgentConfig = serde_json::from_str(json).unwrap();
622        assert_eq!(config.system_prompt, "Hello");
623        assert_eq!(config.model.provider, "openai");
624    }
625
626    #[test]
627    #[cfg(feature = "testkit")]
628    fn config_round_trip_only_contains_restorable_fields() {
629        // Every field in AgentConfig (except `extra` and `tool_names`, which
630        // are documented as metadata-only) must be faithfully restored by
631        // into_agent_options(). This test guards against adding fields
632        // that serialize but silently drop on restore.
633        let config = AgentConfig {
634            system_prompt: "test".into(),
635            model: ModelSpec::new("anthropic", "claude-sonnet"),
636            tool_names: vec!["bash".into()],
637            retry: RetryConfig {
638                max_attempts: 7,
639                base_delay_ms: 500,
640                max_delay_ms: 10_000,
641                multiplier: 1.5,
642                jitter: false,
643            },
644            stream_options: StreamOptionsConfig {
645                temperature: Some(0.3),
646                max_tokens: Some(2048),
647                session_id: Some("s1".into()),
648                transport: StreamTransport::Sse,
649            },
650            steering_mode: SteeringModeConfig::All,
651            follow_up_mode: FollowUpModeConfig::All,
652            structured_output_max_retries: 10,
653            approval_mode: ApprovalModeConfig::Bypassed,
654            plan_mode_addendum: Some("Plan mode text.".into()),
655            cache_config: Some(CacheConfigData {
656                ttl_ms: 60_000,
657                min_tokens: 512,
658                cache_intervals: 3,
659            }),
660            extra: serde_json::json!({"k": "v"}),
661        };
662
663        let stream_fn: std::sync::Arc<dyn crate::stream::StreamFn> =
664            std::sync::Arc::new(crate::testing::MockStreamFn::new(vec![]));
665        let opts = config
666            .clone()
667            .into_agent_options(stream_fn, crate::agent::default_convert);
668
669        assert_eq!(opts.system_prompt, config.system_prompt);
670        assert_eq!(opts.model.provider, config.model.provider);
671        assert_eq!(opts.model.model_id, config.model.model_id);
672        assert_eq!(
673            opts.stream_options.temperature,
674            config.stream_options.temperature
675        );
676        assert_eq!(
677            opts.stream_options.max_tokens,
678            config.stream_options.max_tokens
679        );
680        assert_eq!(
681            opts.structured_output_max_retries,
682            config.structured_output_max_retries
683        );
684        assert!(matches!(
685            opts.steering_mode,
686            crate::agent::SteeringMode::All
687        ));
688        assert!(matches!(
689            opts.follow_up_mode,
690            crate::agent::FollowUpMode::All
691        ));
692        assert!(matches!(
693            opts.approval_mode,
694            crate::tool::ApprovalMode::Bypassed
695        ));
696        assert_eq!(opts.plan_mode_addendum.as_deref(), Some("Plan mode text."));
697        let cc = opts.cache_config.unwrap();
698        assert_eq!(cc.ttl.as_millis(), 60_000);
699        assert_eq!(cc.min_tokens, 512);
700        assert_eq!(cc.cache_intervals, 3);
701    }
702
703    #[test]
704    fn approval_mode_config_roundtrip() {
705        for (mode, expected) in [
706            (ApprovalModeConfig::Enabled, "\"enabled\""),
707            (ApprovalModeConfig::Smart, "\"smart\""),
708            (ApprovalModeConfig::Bypassed, "\"bypassed\""),
709        ] {
710            let json = serde_json::to_string(&mode).unwrap();
711            assert_eq!(json, expected);
712            let back: ApprovalModeConfig = serde_json::from_str(&json).unwrap();
713            assert_eq!(back, mode);
714        }
715    }
716
717    #[test]
718    fn cache_config_data_roundtrip() {
719        let data = CacheConfigData {
720            ttl_ms: 120_000,
721            min_tokens: 2048,
722            cache_intervals: 5,
723        };
724        let cc = data.to_cache_config();
725        assert_eq!(cc.ttl, Duration::from_millis(120_000));
726        assert_eq!(cc.min_tokens, 2048);
727        assert_eq!(cc.cache_intervals, 5);
728
729        let back = CacheConfigData::from(&cc);
730        assert_eq!(back.ttl_ms, 120_000);
731        assert_eq!(back.min_tokens, 2048);
732        assert_eq!(back.cache_intervals, 5);
733    }
734
735    #[test]
736    #[cfg(feature = "testkit")]
737    fn to_config_captures_plan_mode_and_cache() {
738        let stream_fn: std::sync::Arc<dyn crate::stream::StreamFn> =
739            std::sync::Arc::new(crate::testing::MockStreamFn::new(vec![]));
740        let mut opts = crate::agent::AgentOptions::new(
741            "test",
742            crate::types::ModelSpec::new("anthropic", "claude-sonnet"),
743            stream_fn,
744            crate::agent::default_convert,
745        );
746        opts.plan_mode_addendum = Some("custom addendum".into());
747        opts.cache_config = Some(crate::context_cache::CacheConfig::new(
748            Duration::from_secs(300),
749            1024,
750            4,
751        ));
752
753        let config = opts.to_config();
754        assert_eq!(
755            config.plan_mode_addendum.as_deref(),
756            Some("custom addendum")
757        );
758        let cc = config.cache_config.unwrap();
759        assert_eq!(cc.ttl_ms, 300_000);
760        assert_eq!(cc.min_tokens, 1024);
761        assert_eq!(cc.cache_intervals, 4);
762    }
763
764    #[test]
765    fn steering_follow_up_mode_conversions() {
766        // SteeringMode round-trip
767        let all: SteeringModeConfig = crate::agent::SteeringMode::All.into();
768        assert_eq!(all, SteeringModeConfig::All);
769        let back: crate::agent::SteeringMode = all.into();
770        assert!(matches!(back, crate::agent::SteeringMode::All));
771
772        // FollowUpMode round-trip
773        let one: FollowUpModeConfig = crate::agent::FollowUpMode::OneAtATime.into();
774        assert_eq!(one, FollowUpModeConfig::OneAtATime);
775        let back: crate::agent::FollowUpMode = one.into();
776        assert!(matches!(back, crate::agent::FollowUpMode::OneAtATime));
777    }
778}