Skip to main content

sac/cli/
mod.rs

1use std::ffi::{OsStr, OsString};
2use std::io::{self, IsTerminal};
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use clap::{CommandFactory, Parser, Subcommand};
7use tracing::Instrument;
8use uuid::Uuid;
9
10use crate::agent::{Agent, AgentConfig, AgentMode};
11use crate::agents_md::AgentsMdBundle;
12use crate::events::EventSink;
13use crate::mcp::McpRegistry;
14use crate::model::{
15    codex_auth_login, codex_auth_login_access_token, codex_auth_login_api_key,
16    codex_auth_logout, codex_auth_status, BackendKind, ClientOverrides,
17    ModelClient, ReasoningContext, ReasoningEffort, ReasoningSummary,
18};
19use crate::sandbox::{
20    build_sandbox_spec, parse_mount_spec, SandboxSession, DEFAULT_SANDBOX_IMAGE,
21    DEFAULT_SANDBOX_WORKDIR,
22};
23use crate::sessions::{self, SessionSnapshot};
24use crate::skills::{self, SkillRegistry};
25use crate::store::{self, WorkerContext};
26use crate::tui::{self, TuiMetadata, TuiOutcome, UiMode};
27use crate::types::Message;
28
29mod args;
30mod config;
31mod managed_worker;
32mod resume;
33mod sandbox;
34mod upgrade;
35
36use args::*;
37use config::*;
38use managed_worker::*;
39use resume::*;
40use sandbox::*;
41use upgrade::*;
42
43fn resolve_worker_executable_path() -> Result<PathBuf> {
44    let executable =
45        std::env::current_exe().context("failed to determine current sac executable path")?;
46    let canonical = std::fs::canonicalize(&executable).with_context(|| {
47        format!(
48            "failed to canonicalize worker executable {}",
49            executable.display()
50        )
51    })?;
52    let metadata = std::fs::metadata(&canonical)
53        .with_context(|| format!("failed to stat worker executable {}", canonical.display()))?;
54    if !metadata.is_file() {
55        anyhow::bail!(
56            "worker executable path is not a file: {}",
57            canonical.display()
58        );
59    }
60    Ok(canonical)
61}
62
63pub async fn run() -> Result<()> {
64    let cli = parse_cli();
65
66    if let ParsedCli::Config(cli) = cli {
67        run_config_cli(cli)?;
68        return Ok(());
69    }
70
71    match &cli {
72        ParsedCli::Run(run_cli) => {
73            if let Some(dir) = run_cli.directory.as_ref() {
74                std::env::set_current_dir(dir)?;
75            }
76        }
77        ParsedCli::Resume(resume_cli) => {
78            if let Some(dir) = resume_cli.directory.as_ref() {
79                std::env::set_current_dir(dir)?;
80            }
81        }
82        ParsedCli::CodexAuth(_) => {}
83        ParsedCli::Upgrade(_) => {}
84        ParsedCli::ManagedWorker(_) => {}
85        ParsedCli::Config(_) => unreachable!("config handled before terminal checks"),
86    }
87
88    let terminal_available =
89        io::stdin().is_terminal() && io::stdout().is_terminal() && io::stderr().is_terminal();
90    if !matches!(
91        cli,
92        ParsedCli::ManagedWorker(_) | ParsedCli::CodexAuth(_) | ParsedCli::Upgrade(_)
93    ) && !terminal_available
94    {
95        if matches!(&cli, ParsedCli::Resume(resume_cli) if resume_cli.session_id.is_none() && !resume_cli.last)
96        {
97            anyhow::bail!("resume requires an interactive terminal");
98        }
99        anyhow::bail!("interactive mode requires the TUI; run sac from a terminal");
100    }
101
102    if let ParsedCli::CodexAuth(cli) = cli {
103        run_codex_auth_cli(cli).await?;
104        return Ok(());
105    }
106
107    if let ParsedCli::Upgrade(cli) = cli {
108        run_upgrade_cli(cli).await?;
109        return Ok(());
110    }
111
112    let app_config = NacConfig::load()?;
113    let mut run_state = build_run_state(cli, &app_config).await?;
114
115    loop {
116        match run_state {
117            RunState::ManagedWorker(run_config) => {
118                let span = tracing::info_span!(
119                    "sac_run",
120                    role = "worker",
121                    pid = std::process::id(),
122                    log_path = ?crate::logging::current_log_path(),
123                    session_id = %run_config.session_id,
124                    thread_name = %run_config.thread_name,
125                    cwd = %run_config.working_directory,
126                    store_path = %run_config.store_path.display(),
127                    sandbox_state = %run_config.sandbox_status,
128                );
129                run_managed_worker(run_config).instrument(span).await?;
130                return Ok(());
131            }
132            RunState::Orchestrator {
133                run_config,
134                start_in_session_picker,
135                ui_mode,
136            } => {
137                let store_path = run_config.session.store_path();
138                let session_id = run_config.session.session_id().map(str::to_string);
139                let workspace_display = run_config.workspace_display.clone();
140                let sandbox_status = run_config.sandbox_status.clone();
141                let restored_messages = run_config.agent.messages.clone();
142                let session_snapshot = run_config.session.into_snapshot();
143                let agent = run_config.agent;
144                let client = run_config.client;
145                let orchestrator_span = tracing::info_span!(
146                    "sac_run",
147                    role = "orchestrator",
148                    pid = std::process::id(),
149                    log_path = ?crate::logging::current_log_path(),
150                    session_id = ?session_id,
151                    cwd = %workspace_display,
152                    store_path = %store_path.display(),
153                    sandbox_state = %sandbox_status,
154                );
155                let metadata = TuiMetadata {
156                    cwd: workspace_display,
157                    workspace_host_path: run_config.workspace_host_path,
158                    store_path: store_path.clone(),
159                    model: client.model.clone(),
160                    base_url: client.base_url().to_string(),
161                    backend: client.backend().as_str().to_string(),
162                    reasoning_effort: if matches!(
163                        client.backend(),
164                        BackendKind::OpenAiResponses | BackendKind::ChatGptCodexResponses
165                    ) {
166                        client
167                            .reasoning_effort()
168                            .map(|effort| effort.as_str().to_string())
169                    } else {
170                        None
171                    },
172                    session_id,
173                    sandbox_status: run_config.sandbox_status,
174                    agents_md_status: run_config.agents_md_status,
175                };
176
177                match tui::run(
178                    agent,
179                    metadata,
180                    restored_messages,
181                    session_snapshot,
182                    start_in_session_picker,
183                    ui_mode,
184                )
185                .instrument(orchestrator_span)
186                .await?
187                {
188                    TuiOutcome::Exit => return Ok(()),
189                    TuiOutcome::ResumeSession(session_id) => {
190                        run_state = RunState::Orchestrator {
191                            run_config: build_resume_config_for_session(
192                                store_path,
193                                &session_id,
194                                &app_config,
195                            )
196                            .await?,
197                            start_in_session_picker: false,
198                            ui_mode,
199                        };
200                        continue;
201                    }
202                }
203            }
204        }
205    }
206}
207
208async fn build_run_state(cli: ParsedCli, config: &NacConfig) -> Result<RunState> {
209    match cli {
210        ParsedCli::Run(cli) => {
211            let ui_mode = ui_mode_from_args(&cli.ui, config);
212            Ok(RunState::Orchestrator {
213                run_config: build_run_cli_config(cli, config).await?,
214                start_in_session_picker: false,
215                ui_mode,
216            })
217        }
218        ParsedCli::ManagedWorker(cli) => Ok(RunState::ManagedWorker(
219            build_managed_worker_config(cli, config).await?,
220        )),
221        ParsedCli::Resume(cli) if cli.session_id.is_none() && !cli.last => {
222            let ui_mode = ui_mode_from_args(&cli.ui, config);
223            Ok(RunState::Orchestrator {
224                run_config: build_resume_picker_config(cli, config).await?,
225                start_in_session_picker: true,
226                ui_mode,
227            })
228        }
229        ParsedCli::Resume(cli) => {
230            let ui_mode = ui_mode_from_args(&cli.ui, config);
231            Ok(RunState::Orchestrator {
232                run_config: build_resume_config(cli, config).await?,
233                start_in_session_picker: false,
234                ui_mode,
235            })
236        }
237        ParsedCli::Config(_) => unreachable!("config is handled before loading app state"),
238        ParsedCli::CodexAuth(_) => unreachable!("codex-auth is handled before loading config"),
239        ParsedCli::Upgrade(_) => unreachable!("upgrade is handled before loading config"),
240    }
241}
242
243fn run_config_cli(cli: ConfigCli) -> Result<()> {
244    match cli.command.unwrap_or(ConfigCommand::Show) {
245        ConfigCommand::Path => {
246            let path = config_path()
247                .ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
248            println!("{}", path.display());
249            Ok(())
250        }
251        ConfigCommand::LogPath => {
252            let path = crate::logging::current_log_path()
253                .ok_or_else(|| anyhow::anyhow!("unable to resolve sac log path"))?;
254            println!("{}", path.display());
255            Ok(())
256        }
257        ConfigCommand::Logs => {
258            let logs = crate::logging::list_log_files()?;
259            if logs.is_empty() {
260                println!("# No sac log files found");
261            } else {
262                for path in logs {
263                    println!("{}", path.display());
264                }
265            }
266            Ok(())
267        }
268        ConfigCommand::TailLog => {
269            let lines = crate::logging::tail_current_log_lines(40)?;
270            if lines.is_empty() {
271                println!("# Current log file is empty or missing");
272            } else {
273                for line in lines {
274                    println!("{}", line);
275                }
276            }
277            Ok(())
278        }
279        ConfigCommand::Doctor => {
280            let cwd = std::env::current_dir()?;
281            let loaded_config = NacConfig::load()?;
282            let config_path = config_path();
283            let log_path = crate::logging::current_log_path();
284            let log_count = crate::logging::log_file_count()?;
285            let tail = crate::logging::tail_current_log_lines(10)?;
286            let store_path = absolute_store_path(
287                &cwd,
288                loaded_config
289                    .storage
290                    .store_path
291                    .clone()
292                    .unwrap_or_else(store::default_store_path),
293            );
294            let sessions = sessions::list_sessions(&store_path).unwrap_or_default();
295
296            println!("# sac doctor");
297            println!();
298            println!("cwd: {}", cwd.display());
299            println!(
300                "config_path: {}",
301                config_path
302                    .as_ref()
303                    .map(|path| path.display().to_string())
304                    .unwrap_or_else(|| "<unavailable>".to_string())
305            );
306            println!("store_path: {}", store_path.display());
307            println!(
308                "log_path: {}",
309                log_path
310                    .as_ref()
311                    .map(|path| path.display().to_string())
312                    .unwrap_or_else(|| "<unavailable>".to_string())
313            );
314            println!("log_file_count: {}", log_count);
315            println!("session_count: {}", sessions.len());
316            println!();
317            println!("## Recent log lines");
318            if tail.is_empty() {
319                println!("<none>");
320            } else {
321                for line in tail {
322                    println!("{}", line);
323                }
324            }
325            Ok(())
326        }
327        ConfigCommand::Show => {
328            let path = config_path()
329                .ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
330            if path.exists() {
331                let content = std::fs::read_to_string(&path)
332                    .with_context(|| format!("failed to read config {}", path.display()))?;
333                println!("# Configuration from: {}", path.display());
334                println!();
335                print!("{}", content);
336                if !content.ends_with('\n') {
337                    println!();
338                }
339            } else {
340                println!("# No configuration file found at: {}", path.display());
341                println!("# Run `sac config init` to create a sample configuration file");
342            }
343            Ok(())
344        }
345        ConfigCommand::Init => {
346            let path = config_path()
347                .ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
348            if path.exists() {
349                anyhow::bail!(
350                    "configuration file already exists at {}; refusing to overwrite",
351                    path.display()
352                );
353            }
354            if let Some(parent) = path.parent() {
355                std::fs::create_dir_all(parent).with_context(|| {
356                    format!("failed to create config directory {}", parent.display())
357                })?;
358            }
359            std::fs::write(&path, sample_config())
360                .with_context(|| format!("failed to write config {}", path.display()))?;
361            println!("Created sample configuration at {}", path.display());
362            Ok(())
363        }
364        ConfigCommand::Reload => {
365            let path = config_path()
366                .ok_or_else(|| anyhow::anyhow!("unable to resolve sac config path"))?;
367            let config = NacConfig::load()?;
368            println!("Configuration reloaded successfully");
369            println!("Config path: {}", path.display());
370            let summary = config_presence_summary(&config);
371            if summary.is_empty() {
372                println!("Configured entries: none");
373            } else {
374                println!("Configured entries:");
375                for entry in summary {
376                    println!("- {}", entry);
377                }
378            }
379            Ok(())
380        }
381    }
382}
383
384async fn run_codex_auth_cli(cli: CodexAuthCli) -> Result<()> {
385    match cli.command {
386        Some(CodexAuthCommand::Login { with_api_key: true, .. }) => {
387            let mut input = String::new();
388            std::io::stdin().read_line(&mut input)
389                .context("failed to read API key from stdin")?;
390            codex_auth_login_api_key(&input)
391        }
392        Some(CodexAuthCommand::Login { with_access_token: true, .. }) => {
393            let mut input = String::new();
394            std::io::stdin().read_line(&mut input)
395                .context("failed to read access token from stdin")?;
396            codex_auth_login_access_token(&input)
397        }
398        Some(CodexAuthCommand::Login { headless, .. }) => codex_auth_login(headless).await,
399        Some(CodexAuthCommand::Status) => codex_auth_status(),
400        Some(CodexAuthCommand::Logout) => codex_auth_logout().await,
401        None => {
402            let mut command = CodexAuthCli::command();
403            command.print_help()?;
404            println!();
405            Ok(())
406        }
407    }
408}
409
410fn ui_mode_from_args(ui: &UiArgs, config: &NacConfig) -> UiMode {
411    if ui.compact {
412        UiMode::Compact
413    } else if ui.full {
414        UiMode::Full
415    } else if config.ui.mode == Some(UiModeConfig::Compact) {
416        UiMode::Compact
417    } else {
418        UiMode::Full
419    }
420}
421
422struct EffectiveSandboxArgs {
423    sandbox: bool,
424    no_mount_cwd: bool,
425    mounts: Vec<String>,
426    mounts_ro: Vec<String>,
427    sandbox_image: Option<String>,
428    sandbox_gpus: Vec<String>,
429    sandbox_shm_size: Option<String>,
430    sandbox_session_key: Option<String>,
431    sandbox_workdir: Option<String>,
432    explicit_sandbox_config_flags_present: bool,
433}
434
435impl SandboxCliArgs for EffectiveSandboxArgs {
436    fn sandbox_enabled(&self) -> bool {
437        self.sandbox
438    }
439
440    fn no_mount_cwd(&self) -> bool {
441        self.no_mount_cwd
442    }
443
444    fn mounts(&self) -> &[String] {
445        &self.mounts
446    }
447
448    fn mounts_ro(&self) -> &[String] {
449        &self.mounts_ro
450    }
451
452    fn sandbox_image(&self) -> Option<&str> {
453        self.sandbox_image.as_deref()
454    }
455
456    fn sandbox_gpus(&self) -> &[String] {
457        &self.sandbox_gpus
458    }
459
460    fn sandbox_shm_size(&self) -> Option<&String> {
461        self.sandbox_shm_size.as_ref()
462    }
463
464    fn sandbox_session_key(&self) -> Option<&String> {
465        self.sandbox_session_key.as_ref()
466    }
467
468    fn sandbox_workdir(&self) -> Option<&String> {
469        self.sandbox_workdir.as_ref()
470    }
471
472    fn explicit_sandbox_config_flags_present(&self) -> bool {
473        self.explicit_sandbox_config_flags_present
474    }
475}
476
477fn effective_sandbox_args(cli: SandboxArgs, config: &NacConfig) -> EffectiveSandboxArgs {
478    let explicit_sandbox_config_flags_present = cli.explicit_sandbox_config_flags_present();
479    EffectiveSandboxArgs {
480        sandbox: cli.sandbox,
481        no_mount_cwd: cli.no_mount_cwd,
482        mounts: cli.mounts,
483        mounts_ro: cli.mounts_ro,
484        sandbox_image: cli.sandbox_image.or_else(|| config.sandbox.image.clone()),
485        sandbox_gpus: cli.sandbox_gpus,
486        sandbox_shm_size: cli.sandbox_shm_size,
487        sandbox_session_key: cli.sandbox_session_key,
488        sandbox_workdir: cli.sandbox_workdir,
489        explicit_sandbox_config_flags_present,
490    }
491}
492
493fn env_var(name: &str) -> Option<String> {
494    std::env::var(name).ok()
495}
496
497fn configured_api_key_env(config: &NacConfig) -> Option<String> {
498    config
499        .model
500        .api_key_env
501        .as_deref()
502        .filter(|name| !name.trim().is_empty())
503        .map(str::to_string)
504}
505
506fn model_overrides(model: &ModelArgs, config: &NacConfig) -> Result<ClientOverrides> {
507    Ok(ClientOverrides {
508        base_url: model
509            .api_base_url
510            .clone()
511            .or_else(|| config.model.base_url.clone())
512            .or_else(|| env_var("OPENAI_BASE_URL")),
513        model: model
514            .api_model
515            .clone()
516            .or_else(|| config.model.model.clone())
517            .or_else(|| env_var("OPENAI_MODEL")),
518        backend: model.backend.or(config.model.backend),
519        reasoning_effort: model.reasoning_effort.clone().or_else(|| config.model.reasoning_effort.clone()),
520        reasoning_summary: model.reasoning_summary.clone().or_else(|| config.model.reasoning_summary.clone()),
521        reasoning_context: model.reasoning_context.clone().or_else(|| config.model.reasoning_context.clone()),
522        api_key_env: configured_api_key_env(config),
523        api_key: config
524            .model
525            .api_key
526            .as_deref()
527            .filter(|value| !value.trim().is_empty())
528            .map(str::to_string),
529    })
530}
531
532fn worker_thread_timeout_secs(config: &NacConfig) -> u64 {
533    config
534        .worker
535        .thread_timeout_secs
536        .unwrap_or(crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS)
537        .max(crate::tools::thread::MIN_THREAD_TIMEOUT_SECS)
538}
539
540async fn build_run_cli_config(cli: RunCli, config: &NacConfig) -> Result<OrchestratorRunConfig> {
541    let client = ModelClient::from_env_with_overrides(model_overrides(&cli.model, config)?)?;
542    let current_dir = std::env::current_dir()?;
543    let sandbox_args = effective_sandbox_args(cli.sandbox, config);
544    let sandbox = build_sandbox_session(&sandbox_args, &current_dir).await?;
545    let agents_md_workspace_dir = effective_agents_md_workspace_dir(&current_dir, sandbox.as_ref());
546    let agents_md = AgentsMdBundle::load(agents_md_workspace_dir.as_deref())?;
547    let working_directory = sandbox
548        .as_ref()
549        .map(|session| session.workdir_display())
550        .unwrap_or_else(current_directory_display);
551    let workspace_host_path = if let Some(session) = sandbox.as_ref() {
552        session.host_workdir()
553    } else {
554        Some(current_dir.clone())
555    };
556    let sandbox_status = sandbox
557        .as_ref()
558        .map(|session| session.status_text())
559        .unwrap_or_else(|| "off".to_string());
560    let agents_md_message = agents_md.system_message();
561    let agents_md_status = agents_md.status_text();
562
563    let store_path = absolute_store_path(
564        &current_dir,
565        cli.store
566            .store_path
567            .or_else(|| config.storage.store_path.clone())
568            .unwrap_or_else(store::default_store_path),
569    );
570    store::initialize(&store_path)?;
571    let session_id = Uuid::new_v4().to_string();
572    let worker_executable = resolve_worker_executable_path()?;
573    tracing::info!(
574        session_id = %session_id,
575        cwd = %current_dir.display(),
576        backend = ?client.backend(),
577        model = %client.model,
578        base_url = %client.base_url(),
579        worker_executable = %worker_executable.display(),
580        sandbox_status = %sandbox_status,
581        agents_md_status = %agents_md_status,
582        "fresh orchestrator config ready"
583    );
584    let agent = Agent::with_config(
585        client.clone(),
586        AgentConfig {
587            mode: AgentMode::Orchestrator,
588            store_path: store_path.clone(),
589            session_id: Some(session_id.clone()),
590            worker_executable: Some(worker_executable),
591            initial_messages: Vec::new(),
592            thread_name: None,
593            event_sink: EventSink::none(),
594            working_directory: working_directory.clone(),
595            sandbox: sandbox.clone(),
596            mcp: None,
597            skills: None,
598            extra_tool_defs: Vec::new(),
599            agents_md_message,
600            thread_timeout_secs: worker_thread_timeout_secs(config),
601            steering_rx: None,
602        },
603    );
604    let session_snapshot = sessions::new_snapshot(
605        session_id.clone(),
606        current_dir,
607        store_path,
608        client.model.clone(),
609        client.base_url().to_string(),
610        client.backend(),
611        client.reasoning_effort().cloned(),
612        sandbox.as_ref().map(|session| session.spec().clone()),
613        agent.messages.clone(),
614    );
615    sessions::create_session(&session_snapshot)?;
616
617    Ok(OrchestratorRunConfig {
618        agent,
619        client,
620        session: OrchestratorSession::Active {
621            session_id,
622            snapshot: session_snapshot,
623        },
624        sandbox_status,
625        agents_md_status,
626        workspace_display: working_directory,
627        workspace_host_path,
628    })
629}
630
631async fn build_managed_worker_config(
632    cli: ManagedWorkerCli,
633    config: &NacConfig,
634) -> Result<ManagedWorkerRunConfig> {
635    let client = ModelClient::from_env_with_overrides(model_overrides(&cli.model, config)?)?;
636    let current_dir = std::env::current_dir()?;
637    let sandbox_args = effective_sandbox_args(cli.sandbox, config);
638    let sandbox = build_sandbox_session(&sandbox_args, &current_dir).await?;
639    let agents_md_workspace_dir = effective_agents_md_workspace_dir(&current_dir, sandbox.as_ref());
640    let agents_md = AgentsMdBundle::load(agents_md_workspace_dir.as_deref())?;
641    let skills_workspace_dir = effective_skills_workspace_dir(&current_dir, sandbox.as_ref());
642    let working_directory = sandbox
643        .as_ref()
644        .map(|session| session.workdir_display())
645        .unwrap_or_else(current_directory_display);
646    let sandbox_status = sandbox
647        .as_ref()
648        .map(|session| session.status_text())
649        .unwrap_or_else(|| "off".to_string());
650    let agents_md_message = agents_md.system_message();
651    let store_path = absolute_store_path(
652        &current_dir,
653        cli.store
654            .store_path
655            .or_else(|| config.storage.store_path.clone())
656            .unwrap_or_else(store::default_store_path),
657    );
658    let mcp = McpRegistry::load(&current_dir, sandbox.as_ref()).await?;
659    let skills = SkillRegistry::load(skills_workspace_dir.as_deref(), sandbox.as_ref())?;
660    let extra_tool_defs = mcp
661        .as_ref()
662        .map(|registry| registry.tool_definitions())
663        .unwrap_or_default();
664
665    store::initialize(&store_path)?;
666    let worker_context = store::load_worker_context(
667        &store_path,
668        &cli.dispatch.session_id,
669        &cli.dispatch.thread_name,
670        &cli.dispatch.source_threads,
671    )?;
672    let agent = Agent::with_config(
673        client.clone(),
674        AgentConfig {
675            mode: AgentMode::Worker,
676            store_path: store_path.clone(),
677            session_id: Some(cli.dispatch.session_id.clone()),
678            worker_executable: None,
679            initial_messages: build_worker_context_messages(
680                &cli.dispatch.thread_name,
681                &worker_context,
682            ),
683            thread_name: Some(cli.dispatch.thread_name.clone()),
684            event_sink: EventSink::worker_stderr(),
685            working_directory: working_directory.clone(),
686            sandbox,
687            mcp,
688            skills,
689            extra_tool_defs,
690            agents_md_message,
691            thread_timeout_secs: worker_thread_timeout_secs(config),
692            steering_rx: None,
693        },
694    );
695
696    Ok(ManagedWorkerRunConfig {
697        agent,
698        store_path,
699        session_id: cli.dispatch.session_id,
700        thread_name: cli.dispatch.thread_name,
701        action: cli.dispatch.action,
702        working_directory,
703        sandbox_status,
704    })
705}
706
707fn effective_agents_md_workspace_dir(
708    current_dir: &Path,
709    sandbox: Option<&SandboxSession>,
710) -> Option<PathBuf> {
711    if let Some(sandbox) = sandbox {
712        return sandbox.host_workdir();
713    }
714    Some(current_dir.to_path_buf())
715}
716
717fn effective_skills_workspace_dir(
718    current_dir: &Path,
719    sandbox: Option<&SandboxSession>,
720) -> Option<PathBuf> {
721    if let Some(sandbox) = sandbox {
722        return sandbox.host_workdir();
723    }
724    Some(current_dir.to_path_buf())
725}
726
727fn current_directory_display() -> String {
728    std::env::current_dir()
729        .map(|path| path.display().to_string())
730        .unwrap_or_else(|_| ".".to_string())
731}
732
733fn absolute_store_path(cwd: &Path, store_path: PathBuf) -> PathBuf {
734    if store_path.is_absolute() {
735        store_path
736    } else {
737        cwd.join(store_path)
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::test_env_lock;
745
746    fn temp_store_path(label: &str) -> PathBuf {
747        let unique = std::time::SystemTime::now()
748            .duration_since(std::time::UNIX_EPOCH)
749            .expect("time went backwards")
750            .as_nanos();
751        std::env::temp_dir()
752            .join(format!("sac_main_test_{}_{}", label, unique))
753            .join("store.db")
754    }
755
756    fn default_model_args() -> ModelArgs {
757        ModelArgs {
758            backend: None,
759            reasoning_effort: None,
760            reasoning_summary: None,
761            reasoning_context: None,
762            api_base_url: None,
763            api_model: None,
764        }
765    }
766
767    fn default_sandbox_args() -> SandboxArgs {
768        SandboxArgs {
769            sandbox: false,
770            no_mount_cwd: false,
771            mounts: Vec::new(),
772            mounts_ro: Vec::new(),
773            sandbox_image: None,
774            sandbox_gpus: Vec::new(),
775            sandbox_shm_size: None,
776            sandbox_session_key: None,
777            sandbox_workdir: None,
778        }
779    }
780
781    fn default_ui_args() -> UiArgs {
782        UiArgs {
783            compact: false,
784            full: false,
785        }
786    }
787
788    fn restore_env(name: &str, value: Option<OsString>) {
789        match value {
790            Some(value) => unsafe { std::env::set_var(name, value) },
791            None => unsafe { std::env::remove_var(name) },
792        }
793    }
794
795    #[test]
796    fn ui_config_sets_default_and_cli_overrides() {
797        let mut config = NacConfig::default();
798        config.ui.mode = Some(UiModeConfig::Compact);
799
800        assert_eq!(
801            ui_mode_from_args(&default_ui_args(), &config),
802            UiMode::Compact
803        );
804        assert_eq!(
805            ui_mode_from_args(
806                &UiArgs {
807                    compact: false,
808                    full: true,
809                },
810                &config,
811            ),
812            UiMode::Full
813        );
814        assert_eq!(
815            ui_mode_from_args(
816                &UiArgs {
817                    compact: true,
818                    full: false,
819                },
820                &NacConfig::default(),
821            ),
822            UiMode::Compact
823        );
824    }
825
826    #[test]
827    fn model_overrides_prefer_cli_then_config_then_env() {
828        let _guard = test_env_lock();
829        let original_base_url = std::env::var_os("OPENAI_BASE_URL");
830        let original_model = std::env::var_os("OPENAI_MODEL");
831        unsafe {
832            std::env::set_var("OPENAI_BASE_URL", "https://env.example/v1");
833            std::env::set_var("OPENAI_MODEL", "env-model");
834        }
835
836        let mut config = NacConfig::default();
837        config.model.base_url = Some("https://config.example/v1".to_string());
838        config.model.model = Some("config-model".to_string());
839        config.model.backend = Some(BackendKind::OpenAiResponses);
840        config.model.reasoning_effort = Some(ReasoningEffort::High);
841        config.model.api_key_env = Some("SAC_TEST_API_KEY".to_string());
842
843        let env_overrides = model_overrides(&default_model_args(), &config).unwrap();
844        assert_eq!(
845            env_overrides.base_url.as_deref(),
846            Some("https://config.example/v1")
847        );
848        assert_eq!(env_overrides.model.as_deref(), Some("config-model"));
849        assert_eq!(env_overrides.backend, Some(BackendKind::OpenAiResponses));
850        assert_eq!(env_overrides.reasoning_effort, Some(ReasoningEffort::High));
851        assert_eq!(
852            env_overrides.api_key_env.as_deref(),
853            Some("SAC_TEST_API_KEY")
854        );
855
856        let cli_overrides = model_overrides(
857            &ModelArgs {
858                backend: Some(BackendKind::DeepSeekChat),
859                reasoning_effort: Some(ReasoningEffort::Low),
860                reasoning_summary: None,
861                reasoning_context: None,
862                api_base_url: Some("https://cli.example/v1".to_string()),
863                api_model: Some("cli-model".to_string()),
864            },
865            &config,
866        )
867        .unwrap();
868        assert_eq!(
869            cli_overrides.base_url.as_deref(),
870            Some("https://cli.example/v1")
871        );
872        assert_eq!(cli_overrides.model.as_deref(), Some("cli-model"));
873        assert_eq!(cli_overrides.backend, Some(BackendKind::DeepSeekChat));
874        assert_eq!(cli_overrides.reasoning_effort, Some(ReasoningEffort::Low));
875        assert!(cli_overrides.api_key.is_none());
876
877        restore_env("OPENAI_BASE_URL", original_base_url);
878        restore_env("OPENAI_MODEL", original_model);
879    }
880
881    #[test]
882    fn model_overrides_include_config_api_key() {
883        let mut config = NacConfig::default();
884        config.model.api_key = Some("config-secret".to_string());
885
886        let overrides = model_overrides(&default_model_args(), &config).unwrap();
887        assert_eq!(overrides.api_key.as_deref(), Some("config-secret"));
888    }
889
890    #[test]
891    fn sandbox_image_config_is_default_not_enablement() {
892        let mut config = NacConfig::default();
893        config.sandbox.image = Some("custom-image".to_string());
894
895        let disabled = effective_sandbox_args(default_sandbox_args(), &config);
896        assert!(!disabled.sandbox_enabled());
897        assert!(!disabled.explicit_sandbox_config_flags_present());
898        assert_eq!(disabled.sandbox_image(), Some("custom-image"));
899
900        let mut cli = default_sandbox_args();
901        cli.sandbox = true;
902        let enabled = effective_sandbox_args(cli, &config);
903        assert!(enabled.sandbox_enabled());
904        assert_eq!(enabled.sandbox_image(), Some("custom-image"));
905
906        let mut cli = default_sandbox_args();
907        cli.sandbox = true;
908        cli.sandbox_image = Some("cli-image".to_string());
909        let overridden = effective_sandbox_args(cli, &config);
910        assert_eq!(overridden.sandbox_image(), Some("cli-image"));
911        assert!(overridden.explicit_sandbox_config_flags_present());
912    }
913
914    #[test]
915    fn worker_timeout_reads_config_default() {
916        let mut config = NacConfig::default();
917        config.worker.thread_timeout_secs = Some(7_200);
918        assert_eq!(worker_thread_timeout_secs(&config), 7_200);
919
920        config.worker.thread_timeout_secs = Some(10);
921        assert_eq!(
922            worker_thread_timeout_secs(&config),
923            crate::tools::thread::MIN_THREAD_TIMEOUT_SECS
924        );
925    }
926
927    #[test]
928    fn sac_config_loads_new_sections_alongside_existing_mcp() {
929        let _guard = test_env_lock();
930        let original_sac_home = std::env::var_os("SAC_HOME");
931        let root = std::env::temp_dir().join(format!(
932            "sac_config_load_{}",
933            std::time::SystemTime::now()
934                .duration_since(std::time::UNIX_EPOCH)
935                .expect("time went backwards")
936                .as_nanos()
937        ));
938        std::fs::create_dir_all(&root).unwrap();
939        std::fs::write(
940            root.join("config.toml"),
941            r#"
942[ui]
943mode = "compact"
944
945[storage]
946store_path = "custom/store.db"
947
948[model]
949backend = "openai-responses"
950model = "config-model"
951base_url = "https://config.example/v1"
952reasoning_effort = "high"
953api_key_env = "SAC_TEST_API_KEY"
954
955[sandbox]
956image = "config-image"
957
958[worker]
959thread_timeout_secs = 7200
960
961[mcp_servers.context7]
962enabled = true
963transport = "streamable_http"
964url = "https://mcp.context7.com/mcp"
965"#,
966        )
967        .unwrap();
968        unsafe {
969            std::env::set_var("SAC_HOME", &root);
970        }
971
972        let config = NacConfig::load().unwrap();
973        assert_eq!(config.ui.mode, Some(UiModeConfig::Compact));
974        assert_eq!(
975            config.storage.store_path.as_deref(),
976            Some(Path::new("custom/store.db"))
977        );
978        assert_eq!(config.model.backend, Some(BackendKind::OpenAiResponses));
979        assert_eq!(config.model.model.as_deref(), Some("config-model"));
980        assert_eq!(
981            config.model.base_url.as_deref(),
982            Some("https://config.example/v1")
983        );
984        assert_eq!(config.model.reasoning_effort, Some(ReasoningEffort::High));
985        assert_eq!(
986            config.model.api_key_env.as_deref(),
987            Some("SAC_TEST_API_KEY")
988        );
989        assert_eq!(config.sandbox.image.as_deref(), Some("config-image"));
990        assert_eq!(config.worker.thread_timeout_secs, Some(7_200));
991
992        restore_env("SAC_HOME", original_sac_home);
993        let _ = std::fs::remove_dir_all(root);
994    }
995
996    #[test]
997    fn sac_config_loads_model_api_key() {
998        let _guard = test_env_lock();
999        let original_sac_home = std::env::var_os("SAC_HOME");
1000        let root = std::env::temp_dir().join(format!(
1001            "sac_config_api_key_{}",
1002            std::time::SystemTime::now()
1003                .duration_since(std::time::UNIX_EPOCH)
1004                .expect("time went backwards")
1005                .as_nanos()
1006        ));
1007        std::fs::create_dir_all(&root).unwrap();
1008        std::fs::write(
1009            root.join("config.toml"),
1010            "[model]\napi_key = \"config-secret\"\n",
1011        )
1012        .unwrap();
1013        unsafe {
1014            std::env::set_var("SAC_HOME", &root);
1015        }
1016
1017        let config = NacConfig::load().unwrap();
1018        assert_eq!(config.model.api_key.as_deref(), Some("config-secret"));
1019
1020        restore_env("SAC_HOME", original_sac_home);
1021        let _ = std::fs::remove_dir_all(root);
1022    }
1023
1024    #[test]
1025    fn workspace_dir_from_explicit_mount_uses_workspace_guest_mapping() {
1026        let root = std::env::temp_dir().join(format!(
1027            "sac_main_test_workspace_mount_{}",
1028            std::time::SystemTime::now()
1029                .duration_since(std::time::UNIX_EPOCH)
1030                .expect("time went backwards")
1031                .as_nanos()
1032        ));
1033        std::fs::create_dir_all(root.join(".git")).unwrap();
1034
1035        let mounts = vec![crate::sandbox::MountSpec {
1036            host: root.clone(),
1037            guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
1038            read_only: false,
1039        }];
1040
1041        let resolved = workspace_dir_from_mounts(&mounts, PathBuf::from(DEFAULT_SANDBOX_WORKDIR));
1042        assert_eq!(resolved.as_deref(), Some(root.as_path()));
1043
1044        let _ = std::fs::remove_dir_all(root);
1045    }
1046
1047    #[test]
1048    fn parse_resume_command_uses_resume_cli() {
1049        let parsed = parse_cli_from(vec![
1050            OsString::from("sac"),
1051            OsString::from("resume"),
1052            OsString::from("session-123"),
1053        ]);
1054        match parsed {
1055            ParsedCli::Resume(resume) => {
1056                assert_eq!(resume.session_id.as_deref(), Some("session-123"))
1057            }
1058            ParsedCli::Run(_)
1059            | ParsedCli::ManagedWorker(_)
1060            | ParsedCli::Config(_)
1061            | ParsedCli::CodexAuth(_)
1062            | ParsedCli::Upgrade(_) => {
1063                panic!("expected resume cli")
1064            }
1065        }
1066    }
1067
1068    #[test]
1069    fn parse_resume_command_without_id_uses_resume_picker_cli() {
1070        let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("resume")]);
1071        match parsed {
1072            ParsedCli::Resume(resume) => {
1073                assert!(resume.session_id.is_none());
1074                assert!(!resume.last);
1075            }
1076            ParsedCli::Run(_)
1077            | ParsedCli::ManagedWorker(_)
1078            | ParsedCli::Config(_)
1079            | ParsedCli::CodexAuth(_)
1080            | ParsedCli::Upgrade(_) => {
1081                panic!("expected resume cli")
1082            }
1083        }
1084    }
1085
1086    #[test]
1087    fn parse_compact_flag_uses_run_ui_args() {
1088        let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("--compact")]);
1089        match parsed {
1090            ParsedCli::Run(run) => assert!(run.ui.compact),
1091            ParsedCli::Resume(_)
1092            | ParsedCli::ManagedWorker(_)
1093            | ParsedCli::Config(_)
1094            | ParsedCli::CodexAuth(_)
1095            | ParsedCli::Upgrade(_) => {
1096                panic!("expected run cli")
1097            }
1098        }
1099    }
1100
1101    #[test]
1102    fn parse_resume_compact_flag_uses_resume_ui_args() {
1103        let parsed = parse_cli_from(vec![
1104            OsString::from("sac"),
1105            OsString::from("resume"),
1106            OsString::from("--compact"),
1107            OsString::from("--last"),
1108        ]);
1109        match parsed {
1110            ParsedCli::Resume(resume) => {
1111                assert!(resume.ui.compact);
1112                assert!(resume.last);
1113            }
1114            ParsedCli::Run(_)
1115            | ParsedCli::ManagedWorker(_)
1116            | ParsedCli::Config(_)
1117            | ParsedCli::CodexAuth(_)
1118            | ParsedCli::Upgrade(_) => {
1119                panic!("expected resume cli")
1120            }
1121        }
1122    }
1123
1124    #[test]
1125    fn parse_resume_backend_flag_uses_resume_model_args() {
1126        let parsed = parse_cli_from(vec![
1127            OsString::from("sac"),
1128            OsString::from("resume"),
1129            OsString::from("--backend"),
1130            OsString::from("openai-responses"),
1131        ]);
1132        match parsed {
1133            ParsedCli::Resume(resume) => {
1134                assert_eq!(resume.model.backend, Some(BackendKind::OpenAiResponses));
1135            }
1136            ParsedCli::Run(_)
1137            | ParsedCli::ManagedWorker(_)
1138            | ParsedCli::Config(_)
1139            | ParsedCli::CodexAuth(_)
1140            | ParsedCli::Upgrade(_) => {
1141                panic!("expected resume cli")
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn parse_hidden_worker_command_uses_managed_worker_cli() {
1148        let parsed = parse_cli_from(vec![
1149            OsString::from("sac"),
1150            OsString::from("__worker"),
1151            OsString::from("--session-id"),
1152            OsString::from("session-123"),
1153            OsString::from("--thread-name"),
1154            OsString::from("impl"),
1155            OsString::from("--action"),
1156            OsString::from("do work"),
1157            OsString::from("--source-thread"),
1158            OsString::from("research"),
1159        ]);
1160        match parsed {
1161            ParsedCli::ManagedWorker(worker) => {
1162                assert_eq!(worker.dispatch.session_id, "session-123");
1163                assert_eq!(worker.dispatch.thread_name, "impl");
1164                assert_eq!(worker.dispatch.action, "do work");
1165                assert_eq!(worker.dispatch.source_threads, vec!["research"]);
1166            }
1167            ParsedCli::Run(_)
1168            | ParsedCli::Resume(_)
1169            | ParsedCli::Config(_)
1170            | ParsedCli::CodexAuth(_)
1171            | ParsedCli::Upgrade(_) => {
1172                panic!("expected managed worker cli")
1173            }
1174        }
1175    }
1176
1177    #[test]
1178    fn parse_codex_auth_command_uses_codex_auth_cli() {
1179        let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("codex-auth")]);
1180        match parsed {
1181            ParsedCli::CodexAuth(cli) => assert!(cli.command.is_none()),
1182            ParsedCli::Run(_)
1183            | ParsedCli::Resume(_)
1184            | ParsedCli::ManagedWorker(_)
1185            | ParsedCli::Config(_)
1186            | ParsedCli::Upgrade(_) => {
1187                panic!("expected codex-auth cli")
1188            }
1189        }
1190
1191        let parsed = parse_cli_from(vec![
1192            OsString::from("sac"),
1193            OsString::from("codex-auth"),
1194            OsString::from("status"),
1195        ]);
1196        match parsed {
1197            ParsedCli::CodexAuth(cli) => {
1198                assert!(matches!(cli.command, Some(CodexAuthCommand::Status)))
1199            }
1200            ParsedCli::Run(_)
1201            | ParsedCli::Resume(_)
1202            | ParsedCli::ManagedWorker(_)
1203            | ParsedCli::Config(_)
1204            | ParsedCli::Upgrade(_) => {
1205                panic!("expected codex-auth cli")
1206            }
1207        }
1208    }
1209
1210    #[test]
1211    fn parse_config_command_uses_config_cli() {
1212        let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("config")]);
1213        match parsed {
1214            ParsedCli::Config(cli) => assert!(cli.command.is_none()),
1215            ParsedCli::Run(_)
1216            | ParsedCli::Resume(_)
1217            | ParsedCli::ManagedWorker(_)
1218            | ParsedCli::CodexAuth(_)
1219            | ParsedCli::Upgrade(_) => panic!("expected config cli"),
1220        }
1221
1222        let parsed = parse_cli_from(vec![
1223            OsString::from("sac"),
1224            OsString::from("config"),
1225            OsString::from("reload"),
1226        ]);
1227        match parsed {
1228            ParsedCli::Config(cli) => {
1229                assert!(matches!(cli.command, Some(ConfigCommand::Reload)));
1230            }
1231            _ => panic!("expected config cli"),
1232        }
1233
1234        let parsed = parse_cli_from(vec![
1235            OsString::from("sac"),
1236            OsString::from("config"),
1237            OsString::from("log-path"),
1238        ]);
1239        match parsed {
1240            ParsedCli::Config(cli) => {
1241                assert!(matches!(cli.command, Some(ConfigCommand::LogPath)));
1242            }
1243            _ => panic!("expected config cli"),
1244        }
1245
1246        let parsed = parse_cli_from(vec![
1247            OsString::from("sac"),
1248            OsString::from("config"),
1249            OsString::from("logs"),
1250        ]);
1251        match parsed {
1252            ParsedCli::Config(cli) => {
1253                assert!(matches!(cli.command, Some(ConfigCommand::Logs)));
1254            }
1255            _ => panic!("expected config cli"),
1256        }
1257
1258        let parsed = parse_cli_from(vec![
1259            OsString::from("sac"),
1260            OsString::from("config"),
1261            OsString::from("tail-log"),
1262        ]);
1263        match parsed {
1264            ParsedCli::Config(cli) => {
1265                assert!(matches!(cli.command, Some(ConfigCommand::TailLog)));
1266            }
1267            _ => panic!("expected config cli"),
1268        }
1269
1270        let parsed = parse_cli_from(vec![
1271            OsString::from("sac"),
1272            OsString::from("config"),
1273            OsString::from("doctor"),
1274        ]);
1275        match parsed {
1276            ParsedCli::Config(cli) => {
1277                assert!(matches!(cli.command, Some(ConfigCommand::Doctor)));
1278            }
1279            _ => panic!("expected config cli"),
1280        }
1281    }
1282
1283    #[test]
1284    fn sample_config_mentions_supported_model_secret_fields() {
1285        let sample = sample_config();
1286        assert!(sample.contains("api_key_env"));
1287        assert!(sample.contains("api_key ="));
1288    }
1289
1290    #[test]
1291    fn config_presence_summary_masks_api_key_value() {
1292        let mut config = NacConfig::default();
1293        config.model.api_key = Some("super-secret".to_string());
1294        config.model.api_key_env = Some("ALT_KEY_ENV".to_string());
1295        let summary = config_presence_summary(&config);
1296        assert!(summary.iter().any(|entry| entry == "model.api_key=[set]"));
1297        assert!(summary
1298            .iter()
1299            .any(|entry| entry == "model.api_key_env=ALT_KEY_ENV"));
1300        assert!(!summary.iter().any(|entry| entry.contains("super-secret")));
1301    }
1302
1303    #[test]
1304    fn run_config_cli_init_show_and_reload_round_trip() {
1305        let _guard = test_env_lock();
1306        let original_sac_home = std::env::var_os("SAC_HOME");
1307        let root = std::env::temp_dir().join(format!(
1308            "sac_config_cli_roundtrip_{}",
1309            std::time::SystemTime::now()
1310                .duration_since(std::time::UNIX_EPOCH)
1311                .expect("time went backwards")
1312                .as_nanos()
1313        ));
1314        std::fs::create_dir_all(&root).unwrap();
1315        unsafe {
1316            std::env::set_var("SAC_HOME", &root);
1317        }
1318
1319        run_config_cli(ConfigCli {
1320            command: Some(ConfigCommand::Init),
1321        })
1322        .unwrap();
1323
1324        let path = config_path().unwrap();
1325        let content = std::fs::read_to_string(&path).unwrap();
1326        assert!(content.contains("[model]"));
1327        assert!(content.contains("api_key_env"));
1328
1329        run_config_cli(ConfigCli {
1330            command: Some(ConfigCommand::Reload),
1331        })
1332        .unwrap();
1333        run_config_cli(ConfigCli {
1334            command: Some(ConfigCommand::Show),
1335        })
1336        .unwrap();
1337
1338        restore_env("SAC_HOME", original_sac_home);
1339        let _ = std::fs::remove_dir_all(root);
1340    }
1341
1342    #[test]
1343    fn current_log_path_uses_sac_home_layout() {
1344        let _guard = test_env_lock();
1345        let original_sac_home = std::env::var_os("SAC_HOME");
1346        let root = std::env::temp_dir().join(format!(
1347            "sac_log_path_cli_{}",
1348            std::time::SystemTime::now()
1349                .duration_since(std::time::UNIX_EPOCH)
1350                .expect("time went backwards")
1351                .as_nanos()
1352        ));
1353        std::fs::create_dir_all(&root).unwrap();
1354        unsafe {
1355            std::env::set_var("SAC_HOME", &root);
1356        }
1357
1358        let path = crate::paths::sac_log_path_for_pid(std::process::id()).unwrap();
1359        assert_eq!(
1360            path,
1361            root.join("logs")
1362                .join(format!("sac-{}.log", std::process::id()))
1363        );
1364
1365        restore_env("SAC_HOME", original_sac_home);
1366        let _ = std::fs::remove_dir_all(root);
1367    }
1368
1369    #[test]
1370    fn parse_upgrade_command_uses_upgrade_cli() {
1371        let parsed = parse_cli_from(vec![OsString::from("sac"), OsString::from("upgrade")]);
1372        match parsed {
1373            ParsedCli::Upgrade(cli) => assert!(cli.install_dir.is_none()),
1374            ParsedCli::Run(_)
1375            | ParsedCli::Resume(_)
1376            | ParsedCli::ManagedWorker(_)
1377            | ParsedCli::Config(_)
1378            | ParsedCli::CodexAuth(_) => panic!("expected upgrade cli"),
1379        }
1380
1381        let parsed = parse_cli_from(vec![
1382            OsString::from("sac"),
1383            OsString::from("upgrade"),
1384            OsString::from("--install-dir"),
1385            OsString::from("/tmp/sac-bin"),
1386        ]);
1387        match parsed {
1388            ParsedCli::Upgrade(cli) => {
1389                assert_eq!(cli.install_dir.as_deref(), Some(Path::new("/tmp/sac-bin")));
1390            }
1391            ParsedCli::Run(_)
1392            | ParsedCli::Resume(_)
1393            | ParsedCli::ManagedWorker(_)
1394            | ParsedCli::Config(_)
1395            | ParsedCli::CodexAuth(_) => panic!("expected upgrade cli"),
1396        }
1397    }
1398
1399    #[tokio::test]
1400    async fn managed_worker_builds_user_messages_from_self_and_source_threads() {
1401        let _guard = test_env_lock();
1402
1403        let original_api_key = std::env::var("OPENAI_API_KEY").ok();
1404        unsafe {
1405            std::env::set_var("OPENAI_API_KEY", "test_dummy_key");
1406        }
1407
1408        let store_path = temp_store_path("managed_worker_messages");
1409        store::initialize(&store_path).unwrap();
1410
1411        let session_id = "session-msg-order";
1412        store::append_episode(
1413            &store_path,
1414            session_id,
1415            "impl",
1416            "step-1",
1417            "impl retained episode",
1418        )
1419        .unwrap();
1420        store::append_episode(
1421            &store_path,
1422            session_id,
1423            "auth",
1424            "inspect",
1425            "auth latest episode",
1426        )
1427        .unwrap();
1428        store::append_episode(
1429            &store_path,
1430            session_id,
1431            "tests",
1432            "inspect",
1433            "tests latest episode",
1434        )
1435        .unwrap();
1436
1437        let cli = ManagedWorkerCli {
1438            dispatch: WorkerDispatchArgs {
1439                session_id: session_id.to_string(),
1440                thread_name: "impl".to_string(),
1441                action: "implement the next step".to_string(),
1442                source_threads: vec!["auth".to_string(), "tests".to_string()],
1443            },
1444            store: StoreArgs {
1445                store_path: Some(store_path.clone()),
1446            },
1447            model: default_model_args(),
1448            sandbox: default_sandbox_args(),
1449        };
1450
1451        let run_config = build_managed_worker_config(cli, &NacConfig::default())
1452            .await
1453            .unwrap();
1454
1455        assert_eq!(run_config.action, "implement the next step");
1456        let user_messages: Vec<&Message> = run_config
1457            .agent
1458            .messages
1459            .iter()
1460            .filter(|message| matches!(message, Message::User { .. }))
1461            .collect();
1462        assert_eq!(user_messages.len(), 3);
1463
1464        match user_messages[0] {
1465            Message::User { content } => assert!(content.contains("impl retained episode")),
1466            other => panic!("expected self-history user message, got {:?}", other),
1467        }
1468        match user_messages[1] {
1469            Message::User { content } => {
1470                assert!(content.contains("auth latest episode"));
1471                assert!(content.contains("thread \"auth\""));
1472            }
1473            other => panic!("expected first source-thread user message, got {:?}", other),
1474        }
1475        match user_messages[2] {
1476            Message::User { content } => {
1477                assert!(content.contains("tests latest episode"));
1478                assert!(content.contains("thread \"tests\""));
1479            }
1480            other => panic!(
1481                "expected second source-thread user message, got {:?}",
1482                other
1483            ),
1484        }
1485
1486        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
1487
1488        if let Some(key) = original_api_key {
1489            unsafe {
1490                std::env::set_var("OPENAI_API_KEY", key);
1491            }
1492        } else {
1493            unsafe {
1494                std::env::remove_var("OPENAI_API_KEY");
1495            }
1496        }
1497    }
1498
1499    #[test]
1500    fn sandbox_gpu_all_maps_to_nvidia_cdi_device() {
1501        assert_eq!(normalize_gpu_device("all"), "nvidia.com/gpu=all");
1502        assert_eq!(
1503            normalize_gpu_device("nvidia.com/gpu=mig1:0"),
1504            "nvidia.com/gpu=mig1:0"
1505        );
1506    }
1507
1508    #[tokio::test]
1509    async fn resume_config_restores_messages_and_cwd() {
1510        let _guard = test_env_lock();
1511
1512        let original_api_key = std::env::var("OPENAI_API_KEY").ok();
1513        let original_cwd = std::env::current_dir().unwrap();
1514        unsafe {
1515            std::env::set_var("OPENAI_API_KEY", "test_dummy_key");
1516        }
1517        let session_root = std::env::temp_dir().join(format!(
1518            "sac_resume_restore_store_{}",
1519            std::time::SystemTime::now()
1520                .duration_since(std::time::UNIX_EPOCH)
1521                .expect("time went backwards")
1522                .as_nanos()
1523        ));
1524        let session_cwd = session_root.join("repo");
1525        std::fs::create_dir_all(&session_cwd).unwrap();
1526        let store_path = session_cwd.join(".sac/store.db");
1527
1528        let snapshot = sessions::new_snapshot(
1529            "resume-session".to_string(),
1530            session_cwd.clone(),
1531            store_path,
1532            "resume-model".to_string(),
1533            "https://api.openai.com/v1".to_string(),
1534            BackendKind::OpenAiResponses,
1535            Some(ReasoningEffort::Xhigh),
1536            None,
1537            vec![
1538                Message::System {
1539                    content: "system".to_string(),
1540                },
1541                Message::User {
1542                    content: "hello".to_string(),
1543                },
1544                Message::Assistant {
1545                    content: Some("world".to_string()),
1546                    reasoning_text: Some("hidden thinking".to_string()),
1547                    reasoning_details: None,
1548                    tool_calls: None,
1549                },
1550            ],
1551        );
1552        sessions::create_session(&snapshot).unwrap();
1553
1554        std::env::set_current_dir(std::env::temp_dir()).unwrap();
1555        let run_config = build_resume_config(
1556            ResumeCli {
1557                session_id: Some("resume-session".to_string()),
1558                last: false,
1559                directory: Some(session_cwd.clone()),
1560                store: StoreArgs { store_path: None },
1561                ui: default_ui_args(),
1562                model: default_model_args(),
1563            },
1564            &NacConfig::default(),
1565        )
1566        .await
1567        .unwrap();
1568
1569        assert_eq!(
1570            std::env::current_dir().unwrap().canonicalize().unwrap(),
1571            session_cwd.canonicalize().unwrap(),
1572            "resume should restore the stored cwd"
1573        );
1574        assert_eq!(run_config.session.session_id(), Some("resume-session"));
1575        assert_eq!(run_config.agent.messages.len(), 3);
1576        match &run_config.agent.messages[1] {
1577            Message::User { content } => assert_eq!(content, "hello"),
1578            other => panic!("expected restored user message, got {:?}", other),
1579        }
1580        match &run_config.agent.messages[2] {
1581            Message::Assistant {
1582                content: Some(content),
1583                reasoning_text: Some(reasoning),
1584                ..
1585            } => {
1586                assert_eq!(content, "world");
1587                assert_eq!(reasoning, "hidden thinking");
1588            }
1589            other => panic!("expected restored assistant message, got {:?}", other),
1590        }
1591
1592        std::env::set_current_dir(original_cwd).unwrap();
1593        let _ = std::fs::remove_dir_all(session_root);
1594
1595        if let Some(key) = original_api_key {
1596            unsafe {
1597                std::env::set_var("OPENAI_API_KEY", key);
1598            }
1599        } else {
1600            unsafe {
1601                std::env::remove_var("OPENAI_API_KEY");
1602            }
1603        }
1604    }
1605
1606    #[tokio::test]
1607    async fn resume_picker_uses_explicit_backend_override() {
1608        let _guard = test_env_lock();
1609
1610        let original_api_key = std::env::var_os("OPENAI_API_KEY");
1611        let original_base_url = std::env::var_os("OPENAI_BASE_URL");
1612        let original_model = std::env::var_os("OPENAI_MODEL");
1613        let original_cwd = std::env::current_dir().unwrap();
1614
1615        let root = std::env::temp_dir().join(format!(
1616            "sac_resume_picker_backend_{}",
1617            std::time::SystemTime::now()
1618                .duration_since(std::time::UNIX_EPOCH)
1619                .expect("time went backwards")
1620                .as_nanos()
1621        ));
1622        std::fs::create_dir_all(&root).unwrap();
1623
1624        unsafe {
1625            std::env::set_var("OPENAI_API_KEY", "test_dummy_key");
1626            std::env::set_var("OPENAI_BASE_URL", "http://localhost:8317/v1");
1627            std::env::set_var("OPENAI_MODEL", "gpt-5.4");
1628        }
1629
1630        std::env::set_current_dir(std::env::temp_dir()).unwrap();
1631        let run_config = build_resume_picker_config(
1632            ResumeCli {
1633                session_id: None,
1634                last: false,
1635                directory: Some(root.clone()),
1636                store: StoreArgs { store_path: None },
1637                ui: default_ui_args(),
1638                model: ModelArgs {
1639                    backend: Some(BackendKind::OpenAiResponses),
1640                    ..default_model_args()
1641                },
1642            },
1643            &NacConfig::default(),
1644        )
1645        .await
1646        .unwrap();
1647
1648        assert_eq!(run_config.client.backend(), BackendKind::OpenAiResponses);
1649        assert_eq!(run_config.client.base_url(), "http://localhost:8317/v1");
1650        assert_eq!(run_config.client.model, "gpt-5.4");
1651
1652        std::env::set_current_dir(original_cwd).unwrap();
1653        let _ = std::fs::remove_dir_all(root);
1654
1655        restore_env("OPENAI_API_KEY", original_api_key);
1656        restore_env("OPENAI_BASE_URL", original_base_url);
1657        restore_env("OPENAI_MODEL", original_model);
1658    }
1659}