Skip to main content

gemini_cli_sdk/
config.rs

1//! Client configuration.
2//!
3//! The primary entry point for configuring a Gemini CLI session is
4//! [`ClientConfig`], built via the [`TypedBuilder`]-derived builder pattern.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use gemini_cli_sdk::config::{ClientConfig, PermissionMode};
10//!
11//! let config = ClientConfig::builder()
12//!     .prompt("Summarise the repo")
13//!     .model("gemini-2.5-pro")
14//!     .permission_mode(PermissionMode::AcceptEdits)
15//!     .build();
16//!
17//! let args = config.to_cli_args();
18//! assert!(args.contains(&"--approval-mode".to_string()));
19//! ```
20
21use std::collections::{BTreeMap, HashMap};
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27use typed_builder::TypedBuilder;
28
29use crate::hooks::HookMatcher;
30use crate::mcp::McpServers;
31use crate::permissions::CanUseToolCallback;
32
33// Re-export `MessageCallback` so callers can import it from this module,
34// mirroring the claude-cli-sdk API surface.
35pub use crate::callback::MessageCallback;
36
37// ── ClientConfig ────────────────────────────────────────────────────────────
38
39/// Full configuration for a Gemini CLI session.
40///
41/// Built with the [`TypedBuilder`] pattern. Only `prompt` is required; all
42/// other fields have sensible defaults. Gemini-specific fields (`auth_method`,
43/// `sandbox`, `extra_args`) extend the common set.
44///
45/// # Example
46///
47/// ```rust,no_run
48/// use gemini_cli_sdk::config::{ClientConfig, AuthMethod};
49///
50/// let config = ClientConfig::builder()
51///     .prompt("Hello, Gemini!")
52///     .model("gemini-2.5-flash")
53///     .auth_method(AuthMethod::ApiKey)
54///     .verbose(true)
55///     .build();
56/// ```
57#[derive(TypedBuilder)]
58pub struct ClientConfig {
59    // ── Required ────────────────────────────────────────────────────────────
60
61    /// The prompt text sent to the CLI.
62    #[builder(setter(into))]
63    pub prompt: String,
64
65    // ── CLI process ─────────────────────────────────────────────────────────
66
67    /// Override the path to the `gemini` CLI binary.
68    /// When `None`, the binary is located via `PATH` using [`which`].
69    #[builder(default, setter(strip_option))]
70    pub cli_path: Option<PathBuf>,
71
72    /// Working directory for the CLI subprocess.
73    /// Defaults to the current process's working directory when `None`.
74    #[builder(default, setter(strip_option))]
75    pub cwd: Option<PathBuf>,
76
77    // ── Model ───────────────────────────────────────────────────────────────
78
79    /// Gemini model identifier (e.g., `"gemini-2.5-pro"`).
80    #[builder(default, setter(strip_option, into))]
81    pub model: Option<String>,
82
83    // ── Session behaviour ────────────────────────────────────────────────────
84
85    /// Custom system prompt injected into the session.
86    #[builder(default, setter(strip_option))]
87    pub system_prompt: Option<SystemPrompt>,
88
89    /// Maximum number of agentic turns before the session is terminated.
90    #[builder(default, setter(strip_option))]
91    pub max_turns: Option<u32>,
92
93    // ── Tools ───────────────────────────────────────────────────────────────
94
95    /// Tool names the CLI is explicitly permitted to use.
96    /// An empty list means all tools are allowed (subject to `disallowed_tools`).
97    #[builder(default)]
98    pub allowed_tools: Vec<String>,
99
100    /// Tool names that are always denied, overriding `allowed_tools`.
101    #[builder(default)]
102    pub disallowed_tools: Vec<String>,
103
104    // ── Permissions ──────────────────────────────────────────────────────────
105
106    /// Global permission mode passed to the CLI via approval-mode flags.
107    #[builder(default)]
108    pub permission_mode: PermissionMode,
109
110    /// Fine-grained per-tool permission callback, evaluated before each tool
111    /// execution. Takes precedence over `permission_mode` for individual calls.
112    #[builder(default, setter(strip_option))]
113    pub can_use_tool: Option<CanUseToolCallback>,
114
115    // ── Session resume ───────────────────────────────────────────────────────
116
117    /// Session ID to resume. When set, the CLI is invoked with `--resume`.
118    #[builder(default, setter(strip_option, into))]
119    pub resume: Option<String>,
120
121    // ── Hooks ────────────────────────────────────────────────────────────────
122
123    /// Event hooks that fire at defined points in the session lifecycle.
124    #[builder(default)]
125    pub hooks: Vec<HookMatcher>,
126
127    // ── MCP ─────────────────────────────────────────────────────────────────
128
129    /// MCP server configurations forwarded to the CLI at session creation.
130    #[builder(default)]
131    pub mcp_servers: McpServers,
132
133    // ── Callbacks ────────────────────────────────────────────────────────────
134
135    /// Optional callback invoked for each message before it is yielded to the
136    /// stream. Suitable for logging, persistence, or UI updates.
137    #[builder(default, setter(strip_option))]
138    pub message_callback: Option<MessageCallback>,
139
140    // ── Process environment ──────────────────────────────────────────────────
141
142    /// Additional environment variables injected into the CLI subprocess.
143    #[builder(default)]
144    pub env: HashMap<String, String>,
145
146    /// Enable verbose CLI output (passes the `--debug` flag, or equivalent).
147    #[builder(default)]
148    pub verbose: bool,
149
150    // ── Gemini-specific ──────────────────────────────────────────────────────
151
152    /// Authentication method for the Gemini CLI.
153    ///
154    /// Auth is configured via environment variables rather than CLI flags:
155    /// - [`LoginWithGoogle`] — expects the user to have run `gemini auth login`.
156    /// - [`ApiKey`] — reads `GEMINI_API_KEY` from the environment.
157    /// - [`VertexAi`] — reads `GOOGLE_CLOUD_PROJECT` + ADC credentials.
158    ///
159    /// This field does **not** generate CLI arguments in `to_cli_args()`.
160    ///
161    /// [`LoginWithGoogle`]: AuthMethod::LoginWithGoogle
162    /// [`ApiKey`]: AuthMethod::ApiKey
163    /// [`VertexAi`]: AuthMethod::VertexAi
164    #[builder(default, setter(strip_option))]
165    pub auth_method: Option<AuthMethod>,
166
167    /// Run the session inside a sandbox.
168    #[builder(default)]
169    pub sandbox: bool,
170
171    /// Arbitrary extra CLI flags. The map key becomes `--{key}`, the optional
172    /// value is appended as a separate argument when `Some`.
173    #[builder(default)]
174    pub extra_args: BTreeMap<String, Option<String>>,
175
176    // ── Timeouts ─────────────────────────────────────────────────────────────
177
178    /// Timeout for the initial JSON-RPC connection handshake.
179    #[builder(default_code = "Some(Duration::from_secs(30))")]
180    pub connect_timeout: Option<Duration>,
181
182    /// Grace period allowed for the CLI process to exit cleanly on shutdown.
183    #[builder(default_code = "Some(Duration::from_secs(10))")]
184    pub close_timeout: Option<Duration>,
185
186    /// Per-read timeout on the stdio transport. `None` means no timeout.
187    ///
188    /// **Note:** This field is accepted for forward-compatibility but is not yet
189    /// enforced by the transport layer. It is reserved for a future release.
190    #[builder(default)]
191    pub read_timeout: Option<Duration>,
192
193    /// Default timeout budget given to each hook execution.
194    #[builder(default_code = "Duration::from_secs(30)")]
195    pub default_hook_timeout: Duration,
196
197    /// Timeout for the background CLI version check. `None` skips the check.
198    ///
199    /// **Note:** This field is accepted for forward-compatibility but is not yet
200    /// enforced by the transport layer. It is reserved for a future release.
201    #[builder(default_code = "Some(Duration::from_secs(5))")]
202    pub version_check_timeout: Option<Duration>,
203
204    // ── Diagnostics ──────────────────────────────────────────────────────────
205
206    /// Optional callback that receives every line written to the CLI's stderr.
207    /// Useful for surfacing CLI-level warnings and errors to callers.
208    #[builder(default, setter(strip_option))]
209    pub stderr_callback: Option<Arc<dyn Fn(String) + Send + Sync>>,
210}
211
212impl ClientConfig {
213    /// Translate this configuration into CLI arguments for the Gemini binary.
214    ///
215    /// The ACP mode flag (`--experimental-acp`) is always prepended. Remaining
216    /// flags are derived from the fields that have non-default values.
217    ///
218    /// # Example
219    ///
220    /// ```rust
221    /// use gemini_cli_sdk::config::{ClientConfig, PermissionMode};
222    ///
223    /// let config = ClientConfig::builder()
224    ///     .prompt("test")
225    ///     .permission_mode(PermissionMode::BypassPermissions)
226    ///     .build();
227    ///
228    /// let args = config.to_cli_args();
229    /// assert!(args.contains(&"--yolo".to_string()));
230    /// ```
231    pub fn to_cli_args(&self) -> Vec<String> {
232        let mut args = vec!["--experimental-acp".to_string()];
233
234        if let Some(model) = &self.model {
235            args.push("--model".to_string());
236            args.push(model.clone());
237        }
238
239        if self.sandbox {
240            args.push("--sandbox".to_string());
241        }
242
243        match self.permission_mode {
244            PermissionMode::Default => {}
245            PermissionMode::AcceptEdits => {
246                args.push("--approval-mode".to_string());
247                args.push("auto_edit".to_string());
248            }
249            PermissionMode::Plan => {
250                args.push("--approval-mode".to_string());
251                args.push("plan".to_string());
252            }
253            PermissionMode::BypassPermissions => {
254                args.push("--yolo".to_string());
255            }
256        }
257
258        if self.verbose {
259            args.push("--debug".to_string());
260        }
261
262        if let Some(turns) = self.max_turns {
263            args.push("--max-turns".to_string());
264            args.push(turns.to_string());
265        }
266
267        if let Some(sp) = &self.system_prompt {
268            match sp {
269                SystemPrompt::Text(text) => {
270                    args.push("--system-prompt".to_string());
271                    args.push(text.clone());
272                }
273                SystemPrompt::File(path) => {
274                    args.push("--system-prompt-file".to_string());
275                    args.push(path.to_string_lossy().to_string());
276                }
277            }
278        }
279
280        if !self.allowed_tools.is_empty() {
281            args.push("--allowed-tools".to_string());
282            args.push(self.allowed_tools.join(","));
283        }
284
285        if !self.disallowed_tools.is_empty() {
286            args.push("--disallowed-tools".to_string());
287            args.push(self.disallowed_tools.join(","));
288        }
289
290        for (key, value) in &self.extra_args {
291            args.push(format!("--{key}"));
292            if let Some(v) = value {
293                args.push(v.clone());
294            }
295        }
296
297        args
298    }
299}
300
301// ── AuthMethod ───────────────────────────────────────────────────────────────
302
303/// Authentication method passed to the Gemini CLI.
304///
305/// Determines how the CLI authenticates with Google's APIs. The default
306/// (when `None` is set on [`ClientConfig`]) defers to the CLI's own
307/// credential resolution order.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub enum AuthMethod {
310    /// Interactive OAuth 2.0 flow — opens a browser window on first run.
311    LoginWithGoogle,
312    /// API key passed via the `GEMINI_API_KEY` environment variable.
313    ApiKey,
314    /// Vertex AI service account credentials.
315    VertexAi,
316}
317
318// ── PermissionMode ───────────────────────────────────────────────────────────
319
320/// Global permission mode controlling how the CLI handles tool approval.
321///
322/// Corresponds to the `--approval-mode` CLI flag, with [`BypassPermissions`]
323/// mapping to `--yolo` for compatibility with the Gemini CLI's flag naming.
324///
325/// [`BypassPermissions`]: PermissionMode::BypassPermissions
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
327pub enum PermissionMode {
328    /// Default interactive approval — prompts for confirmation before tools run.
329    #[default]
330    Default,
331    /// Automatically approve edit operations without prompting.
332    AcceptEdits,
333    /// Plan-only mode — the CLI describes actions but does not execute them.
334    Plan,
335    /// Skip all approval prompts. Use with caution in automated environments.
336    BypassPermissions,
337}
338
339// ── SystemPrompt ─────────────────────────────────────────────────────────────
340
341/// System prompt configuration.
342///
343/// The system prompt is injected into the session before the user's first
344/// message. It can be provided as inline text or as a path to a file on disk
345/// (which the CLI reads at startup).
346#[derive(Debug, Clone)]
347pub enum SystemPrompt {
348    /// Inline system prompt text.
349    Text(String),
350    /// Path to a file containing the system prompt.
351    File(PathBuf),
352}
353
354// ── Tests ────────────────────────────────────────────────────────────────────
355
356#[cfg(test)]
357mod tests {
358    use std::collections::BTreeMap;
359
360    use super::{AuthMethod, ClientConfig, PermissionMode};
361
362    // ── Builder construction ─────────────────────────────────────────────────
363
364    #[test]
365    fn test_config_builder_minimal() {
366        // Only the required `prompt` field is set — all other fields take
367        // their defaults. This verifies the builder compiles and produces a
368        // valid struct without panicking.
369        let config = ClientConfig::builder().prompt("test").build();
370        assert_eq!(config.prompt, "test");
371        assert!(config.cli_path.is_none());
372        assert!(config.model.is_none());
373        assert!(config.cwd.is_none());
374        assert!(config.allowed_tools.is_empty());
375        assert!(config.disallowed_tools.is_empty());
376        assert!(config.hooks.is_empty());
377        assert!(config.mcp_servers.is_empty());
378        assert!(!config.sandbox);
379        assert!(!config.verbose);
380    }
381
382    #[test]
383    fn test_config_builder_with_model() {
384        let config = ClientConfig::builder()
385            .prompt("hello")
386            .model("gemini-2.5-pro")
387            .build();
388
389        assert_eq!(config.model.as_deref(), Some("gemini-2.5-pro"));
390    }
391
392    // ── to_cli_args ──────────────────────────────────────────────────────────
393
394    #[test]
395    fn test_to_cli_args_default() {
396        // Default config produces only the mandatory ACP flag.
397        let config = ClientConfig::builder().prompt("test").build();
398        let args = config.to_cli_args();
399        assert_eq!(args, vec!["--experimental-acp"]);
400    }
401
402    #[test]
403    fn test_to_cli_args_with_model() {
404        let config = ClientConfig::builder()
405            .prompt("test")
406            .model("gemini-2.5-flash")
407            .build();
408        let args = config.to_cli_args();
409        assert!(
410            args.contains(&"--model".to_string()),
411            "expected --model flag"
412        );
413        assert!(
414            args.contains(&"gemini-2.5-flash".to_string()),
415            "expected model value"
416        );
417        // Flags must be adjacent: --model <value>
418        let model_pos = args.iter().position(|a| a == "--model").unwrap();
419        assert_eq!(args[model_pos + 1], "gemini-2.5-flash");
420    }
421
422    #[test]
423    fn test_to_cli_args_accept_edits() {
424        let config = ClientConfig::builder()
425            .prompt("test")
426            .permission_mode(PermissionMode::AcceptEdits)
427            .build();
428        let args = config.to_cli_args();
429        assert!(
430            args.contains(&"--approval-mode".to_string()),
431            "expected --approval-mode"
432        );
433        assert!(
434            args.contains(&"auto_edit".to_string()),
435            "expected auto_edit value"
436        );
437        let pos = args.iter().position(|a| a == "--approval-mode").unwrap();
438        assert_eq!(args[pos + 1], "auto_edit");
439    }
440
441    #[test]
442    fn test_to_cli_args_plan_mode() {
443        let config = ClientConfig::builder()
444            .prompt("test")
445            .permission_mode(PermissionMode::Plan)
446            .build();
447        let args = config.to_cli_args();
448        let pos = args.iter().position(|a| a == "--approval-mode").unwrap();
449        assert_eq!(args[pos + 1], "plan");
450    }
451
452    #[test]
453    fn test_to_cli_args_bypass() {
454        let config = ClientConfig::builder()
455            .prompt("test")
456            .permission_mode(PermissionMode::BypassPermissions)
457            .build();
458        let args = config.to_cli_args();
459        assert!(
460            args.contains(&"--yolo".to_string()),
461            "BypassPermissions must map to --yolo"
462        );
463        // --yolo and --approval-mode are mutually exclusive paths — ensure no
464        // approval-mode flag leaks through alongside --yolo.
465        assert!(
466            !args.contains(&"--approval-mode".to_string()),
467            "--approval-mode must not appear alongside --yolo"
468        );
469    }
470
471    #[test]
472    fn test_to_cli_args_sandbox() {
473        let config = ClientConfig::builder()
474            .prompt("test")
475            .sandbox(true)
476            .build();
477        let args = config.to_cli_args();
478        assert!(
479            args.contains(&"--sandbox".to_string()),
480            "sandbox=true must emit --sandbox"
481        );
482    }
483
484    #[test]
485    fn test_to_cli_args_sandbox_false_omitted() {
486        // sandbox defaults to false; the flag must not appear.
487        let config = ClientConfig::builder().prompt("test").build();
488        let args = config.to_cli_args();
489        assert!(
490            !args.contains(&"--sandbox".to_string()),
491            "--sandbox must not appear when sandbox=false"
492        );
493    }
494
495    #[test]
496    fn test_to_cli_args_extra() {
497        let mut extra = BTreeMap::new();
498        extra.insert("temperature".to_string(), Some("0.7".to_string()));
499        extra.insert("top-p".to_string(), None);
500
501        let config = ClientConfig::builder()
502            .prompt("test")
503            .extra_args(extra)
504            .build();
505        let args = config.to_cli_args();
506
507        assert!(
508            args.contains(&"--temperature".to_string()),
509            "extra key must become --<key>"
510        );
511        assert!(
512            args.contains(&"0.7".to_string()),
513            "extra value must appear as a separate arg"
514        );
515        assert!(
516            args.contains(&"--top-p".to_string()),
517            "flag-only extra arg must appear without a value"
518        );
519    }
520
521    #[test]
522    fn test_to_cli_args_extra_btreemap_ordering() {
523        // BTreeMap is sorted alphabetically; verify the output order is stable.
524        let mut extra = BTreeMap::new();
525        extra.insert("zzz".to_string(), Some("last".to_string()));
526        extra.insert("aaa".to_string(), Some("first".to_string()));
527
528        let config = ClientConfig::builder()
529            .prompt("test")
530            .extra_args(extra)
531            .build();
532        let args = config.to_cli_args();
533
534        // --experimental-acp comes first; then aaa, then zzz (alpha order).
535        let aaa_pos = args.iter().position(|a| a == "--aaa").unwrap();
536        let zzz_pos = args.iter().position(|a| a == "--zzz").unwrap();
537        assert!(aaa_pos < zzz_pos, "--aaa must precede --zzz (BTreeMap order)");
538    }
539
540    // ── PermissionMode ───────────────────────────────────────────────────────
541
542    #[test]
543    fn test_permission_mode_default() {
544        assert_eq!(
545            PermissionMode::default(),
546            PermissionMode::Default,
547            "Default must be the zero-value for PermissionMode"
548        );
549    }
550
551    #[test]
552    fn test_permission_mode_debug() {
553        // Ensure all variants have a Debug impl (derived).
554        let _ = format!("{:?}", PermissionMode::Default);
555        let _ = format!("{:?}", PermissionMode::AcceptEdits);
556        let _ = format!("{:?}", PermissionMode::Plan);
557        let _ = format!("{:?}", PermissionMode::BypassPermissions);
558    }
559
560    // ── AuthMethod ───────────────────────────────────────────────────────────
561
562    #[test]
563    fn test_auth_method_serde_roundtrip() {
564        for variant in [
565            AuthMethod::LoginWithGoogle,
566            AuthMethod::ApiKey,
567            AuthMethod::VertexAi,
568        ] {
569            let json = serde_json::to_string(&variant).expect("serialize");
570            let recovered: AuthMethod = serde_json::from_str(&json).expect("deserialize");
571            assert_eq!(variant, recovered, "serde roundtrip failed for {json}");
572        }
573    }
574
575    #[test]
576    fn test_auth_method_partial_eq() {
577        assert_eq!(AuthMethod::ApiKey, AuthMethod::ApiKey);
578        assert_ne!(AuthMethod::ApiKey, AuthMethod::VertexAi);
579    }
580
581    // ── Timeouts ─────────────────────────────────────────────────────────────
582
583    #[test]
584    fn test_default_timeouts() {
585        let config = ClientConfig::builder().prompt("test").build();
586        assert_eq!(
587            config.connect_timeout,
588            Some(std::time::Duration::from_secs(30)),
589            "connect_timeout default must be 30 s"
590        );
591        assert_eq!(
592            config.close_timeout,
593            Some(std::time::Duration::from_secs(10)),
594            "close_timeout default must be 10 s"
595        );
596        assert_eq!(
597            config.default_hook_timeout,
598            std::time::Duration::from_secs(30),
599            "hook timeout default must be 30 s"
600        );
601        assert_eq!(
602            config.version_check_timeout,
603            Some(std::time::Duration::from_secs(5)),
604            "version_check_timeout default must be 5 s"
605        );
606        assert!(
607            config.read_timeout.is_none(),
608            "read_timeout must default to None"
609        );
610    }
611
612    // ── Verbose flag ─────────────────────────────────────────────────────────
613
614    #[test]
615    fn test_to_cli_args_verbose() {
616        let config = ClientConfig::builder()
617            .prompt("test")
618            .verbose(true)
619            .build();
620        let args = config.to_cli_args();
621        assert!(
622            args.contains(&"--debug".to_string()),
623            "verbose=true must emit --debug"
624        );
625    }
626
627    #[test]
628    fn test_to_cli_args_verbose_false_omitted() {
629        let config = ClientConfig::builder().prompt("test").build();
630        let args = config.to_cli_args();
631        assert!(
632            !args.contains(&"--debug".to_string()),
633            "--debug must not appear when verbose=false"
634        );
635    }
636
637    #[test]
638    fn test_to_cli_args_max_turns() {
639        let config = ClientConfig::builder()
640            .prompt("test")
641            .max_turns(5_u32)
642            .build();
643        let args = config.to_cli_args();
644        let pos = args.iter().position(|a| a == "--max-turns").expect("--max-turns missing");
645        assert_eq!(args[pos + 1], "5");
646    }
647
648    #[test]
649    fn test_to_cli_args_system_prompt_text() {
650        let config = ClientConfig::builder()
651            .prompt("test")
652            .system_prompt(crate::config::SystemPrompt::Text("You are helpful.".to_string()))
653            .build();
654        let args = config.to_cli_args();
655        let pos = args.iter().position(|a| a == "--system-prompt").expect("--system-prompt missing");
656        assert_eq!(args[pos + 1], "You are helpful.");
657    }
658
659    #[test]
660    fn test_to_cli_args_system_prompt_file() {
661        let config = ClientConfig::builder()
662            .prompt("test")
663            .system_prompt(crate::config::SystemPrompt::File(
664                std::path::PathBuf::from("/etc/prompt.txt"),
665            ))
666            .build();
667        let args = config.to_cli_args();
668        let pos = args.iter().position(|a| a == "--system-prompt-file").expect("--system-prompt-file missing");
669        assert_eq!(args[pos + 1], "/etc/prompt.txt");
670    }
671
672    #[test]
673    fn test_to_cli_args_allowed_tools() {
674        let config = ClientConfig::builder()
675            .prompt("test")
676            .allowed_tools(vec!["read_file".to_string(), "write_file".to_string()])
677            .build();
678        let args = config.to_cli_args();
679        let pos = args.iter().position(|a| a == "--allowed-tools").expect("--allowed-tools missing");
680        assert_eq!(args[pos + 1], "read_file,write_file");
681    }
682
683    #[test]
684    fn test_to_cli_args_disallowed_tools() {
685        let config = ClientConfig::builder()
686            .prompt("test")
687            .disallowed_tools(vec!["shell".to_string()])
688            .build();
689        let args = config.to_cli_args();
690        let pos = args.iter().position(|a| a == "--disallowed-tools").expect("--disallowed-tools missing");
691        assert_eq!(args[pos + 1], "shell");
692    }
693
694    #[test]
695    fn test_to_cli_args_empty_tool_lists_omitted() {
696        // Default (empty) lists must NOT emit any flags.
697        let config = ClientConfig::builder().prompt("test").build();
698        let args = config.to_cli_args();
699        assert!(!args.contains(&"--allowed-tools".to_string()));
700        assert!(!args.contains(&"--disallowed-tools".to_string()));
701    }
702
703    // ── experimental-acp always present ─────────────────────────────────────
704
705    #[test]
706    fn test_to_cli_args_always_has_acp_flag() {
707        // Regardless of other settings, the ACP flag is always the first arg.
708        let config = ClientConfig::builder()
709            .prompt("p")
710            .model("gemini-2.5-pro")
711            .sandbox(true)
712            .permission_mode(PermissionMode::BypassPermissions)
713            .build();
714        let args = config.to_cli_args();
715        assert_eq!(
716            args[0], "--experimental-acp",
717            "--experimental-acp must always be the first argument"
718        );
719    }
720}