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, ¤t_dir).await?;
545 let agents_md_workspace_dir = effective_agents_md_workspace_dir(¤t_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 ¤t_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, ¤t_dir).await?;
639 let agents_md_workspace_dir = effective_agents_md_workspace_dir(¤t_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(¤t_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 ¤t_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(¤t_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}