diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 0e2b93972..2e5d98e96 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -235,7 +235,7 @@ pub struct AgentDriverOptions { /// Selected execution harness for this run. pub selected_harness: Harness, /// Model ID for the selected harness. Only used for non-Oz harnesses. - pub harness_model_id: Option, + pub third_party_harness_model_id: Option, /// Whether to skip end-of-run snapshot upload. pub snapshot_disabled: Option, /// End-of-run snapshot upload timeout override. @@ -300,6 +300,7 @@ pub struct AgentDriver { /// conversation's `parent_agent_id` field at register time so the /// streamer recognizes the child role in driver-hosted processes. parent_run_id: Option, + third_party_harness_model_id: Option, } pub(crate) enum SDKConversationOutputStatus { @@ -490,7 +491,7 @@ impl AgentDriver { cloud_providers, environment, selected_harness, - harness_model_id, + third_party_harness_model_id, snapshot_disabled, snapshot_upload_timeout, snapshot_script_timeout, @@ -616,7 +617,7 @@ impl AgentDriver { )); env_vars.extend(harness_model_env_vars( selected_harness, - harness_model_id.as_deref(), + third_party_harness_model_id.as_deref(), )); // Signal to third-party harnesses (e.g. Claude Code) that we're in a sandbox @@ -671,6 +672,7 @@ impl AgentDriver { resume_payload, run_conversation_id, parent_run_id: parent_run_id_for_self, + third_party_harness_model_id, }) } @@ -1569,11 +1571,21 @@ impl AgentDriver { }; // Prepare harness config files (onboarding, trust dialog, API-key approval, etc.). - let secrets = foreground - .spawn(|me, _| Arc::clone(&me.secrets)) + let (secrets, third_party_harness_model_id) = foreground + .spawn(|me, _| { + ( + Arc::clone(&me.secrets), + me.third_party_harness_model_id.clone(), + ) + }) .await .map_err(|_| AgentDriverError::InvalidRuntimeState)?; - harness.prepare_environment_config(&working_dir, system_prompt.as_deref(), &secrets)?; + harness.prepare_environment_config( + &working_dir, + system_prompt.as_deref(), + &secrets, + third_party_harness_model_id.as_deref(), + )?; // Pull the resume payload off the driver so the harness runner can rehydrate any // existing session/conversation state before launching its CLI. The payload variant diff --git a/app/src/ai/agent_sdk/driver/harness/claude_code.rs b/app/src/ai/agent_sdk/driver/harness/claude_code.rs index b6761eb61..c7003fd6b 100644 --- a/app/src/ai/agent_sdk/driver/harness/claude_code.rs +++ b/app/src/ai/agent_sdk/driver/harness/claude_code.rs @@ -68,6 +68,7 @@ impl ThirdPartyHarness for ClaudeHarness { working_dir: &Path, _system_prompt: Option<&str>, secrets: &HashMap, + _third_party_harness_model_id: Option<&str>, ) -> Result<(), AgentDriverError> { prepare_claude_environment_config(working_dir, secrets).map_err(|error| { AgentDriverError::HarnessConfigSetupFailed { diff --git a/app/src/ai/agent_sdk/driver/harness/codex.rs b/app/src/ai/agent_sdk/driver/harness/codex.rs index 37ccc1da6..79e6f612e 100644 --- a/app/src/ai/agent_sdk/driver/harness/codex.rs +++ b/app/src/ai/agent_sdk/driver/harness/codex.rs @@ -52,12 +52,17 @@ impl ThirdPartyHarness for CodexHarness { working_dir: &Path, system_prompt: Option<&str>, secrets: &HashMap, + third_party_harness_model_id: Option<&str>, ) -> Result<(), AgentDriverError> { - prepare_codex_environment_config(working_dir, system_prompt, secrets).map_err(|error| { - AgentDriverError::HarnessConfigSetupFailed { - harness: self.cli_agent().command_prefix().to_owned(), - error, - } + prepare_codex_environment_config( + working_dir, + system_prompt, + secrets, + third_party_harness_model_id, + ) + .map_err(|error| AgentDriverError::HarnessConfigSetupFailed { + harness: self.cli_agent().command_prefix().to_owned(), + error, }) } @@ -226,6 +231,15 @@ const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted"; /// Top-level config key codex reads to override the built-in `openai` provider's base URL /// (codex `core/src/config/mod.rs`). const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url"; +const CODEX_MODEL_KEY: &str = "model"; +/// Target model for the `[notice.model_migrations]` table that suppresses Codex's +/// "choose a newer model" upgrade prompt at session launch. We stamp this for any +/// pinned model id (even when it already matches the target) so the unattended +/// cloud run never blocks on the prompt. +/// +/// TODO: Ideally, we would make this server-driven so we don't depend on a client +/// release to change this. +const CODEX_MODEL_MIGRATIONS_TARGET: &str = "gpt-5.4"; /// US data-residency endpoint. Our OpenAI keys are issued under a US-residency project, /// which rejects requests to the global host with `401 incorrect_hostname`. /// TODO(REMOTE-1509): plumb a region-tagged auth secret instead of hardcoding the URL. @@ -235,6 +249,7 @@ fn prepare_codex_environment_config( working_dir: &Path, system_prompt: Option<&str>, secrets: &HashMap, + third_party_harness_model_id: Option<&str>, ) -> Result<()> { let home_dir = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; @@ -249,7 +264,11 @@ fn prepare_codex_environment_config( None => log::info!("No OPENAI_API_KEY available; skipping Codex auth.json seed"), } - prepare_codex_config_toml(&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME), working_dir)?; + prepare_codex_config_toml( + &codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME), + working_dir, + third_party_harness_model_id, + )?; Ok(()) } @@ -353,7 +372,14 @@ fn resolve_openai_api_key(secrets: &HashMap) -> Opti /// set the projects to `trusted`. /// - base URL: set `openai_base_url = ""` so we /// hit the regional host our API keys require. -fn prepare_codex_config_toml(config_toml_path: &Path, working_dir: &Path) -> Result<()> { +/// - model override: when a non-default `third_party_harness_model_id` is +/// supplied, write the top-level `model` key so Codex pins the chosen model +/// for new sessions. +fn prepare_codex_config_toml( + config_toml_path: &Path, + working_dir: &Path, + third_party_harness_model_id: Option<&str>, +) -> Result<()> { let existing = match fs::read_to_string(config_toml_path) { Ok(content) => content, Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), @@ -372,6 +398,7 @@ fn prepare_codex_config_toml(config_toml_path: &Path, working_dir: &Path) -> Res })?; set_codex_openai_base_url(&mut doc, CODEX_OPENAI_BASE_URL); + set_codex_model(&mut doc, third_party_harness_model_id); let canonical = working_dir.canonicalize().with_context(|| { format!( @@ -408,6 +435,48 @@ fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) { doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url); } +fn set_codex_model( + doc: &mut toml_edit::DocumentMut, + third_party_harness_model_id: Option<&str>, +) { + let Some(model_id) = + third_party_harness_model_id.filter(|id| !id.is_empty() && *id != "default") + else { + return; + }; + doc[CODEX_MODEL_KEY] = toml_edit::value(model_id); + + // Codex's TUI prompts the user to upgrade older models on session launch even when + // a `model` key has been pinned. Stamping a migration entry keyed on the chosen + // model id suppresses that prompt for the unattended cloud run. We do this + // unconditionally rather than enumerating a list of "old" models on the client: + // mapping the migration target to itself (e.g. `gpt-5.4 = "gpt-5.4"`) is a no-op + // for Codex, and keeping the client free of model-version knowledge means we + // don't have to ship a client update every time Anthropic/OpenAI ages out a model. + set_codex_model_migration(doc, model_id, CODEX_MODEL_MIGRATIONS_TARGET); +} + +fn set_codex_model_migration( + doc: &mut toml_edit::DocumentMut, + from_model_id: &str, + to_model_id: &str, +) { + if !doc.contains_table("notice") { + let mut notice_tbl = toml_edit::Table::new(); + notice_tbl.set_implicit(true); + doc.insert("notice", toml_edit::Item::Table(notice_tbl)); + } + let migrations_tbl = doc["notice"] + .as_table_mut() + .expect("notice table inserted above") + .entry("model_migrations") + .or_insert_with(toml_edit::table) + .as_table_mut() + .expect("model_migrations entry is a table"); + migrations_tbl.set_implicit(false); + migrations_tbl[from_model_id] = toml_edit::value(to_model_id); +} + /// Return immediate subdirectories of `dir` that contain a `.git`. fn find_child_git_repos(dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(dir) else { diff --git a/app/src/ai/agent_sdk/driver/harness/codex_tests.rs b/app/src/ai/agent_sdk/driver/harness/codex_tests.rs index c477d71f4..2b408f1b9 100644 --- a/app/src/ai/agent_sdk/driver/harness/codex_tests.rs +++ b/app/src/ai/agent_sdk/driver/harness/codex_tests.rs @@ -172,7 +172,7 @@ fn prepare_codex_config_toml_writes_fresh_config() { let working_dir = tmp.path().join("workspace/proj"); fs::create_dir_all(&working_dir).unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let canonical = working_dir.canonicalize().unwrap(); let key = canonical.to_string_lossy().into_owned(); @@ -196,7 +196,8 @@ fn prepare_codex_config_toml_preserves_unrelated_keys() { ) .unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + // Pass `None` for the model id so the helper preserves the user's existing `model = ...`. + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let canonical = working_dir.canonicalize().unwrap(); let key = canonical.to_string_lossy().into_owned(); @@ -219,9 +220,9 @@ fn prepare_codex_config_toml_is_idempotent() { let working_dir = tmp.path().join("workspace"); fs::create_dir_all(&working_dir).unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let after_first = fs::read_to_string(&config_path).unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let after_second = fs::read_to_string(&config_path).unwrap(); assert_eq!(after_first, after_second); @@ -250,7 +251,7 @@ fn prepare_codex_config_toml_upgrades_untrusted_entry() { ) .unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let cfg = read_codex_config(&config_path); assert_eq!( @@ -269,7 +270,7 @@ fn prepare_codex_config_toml_trusts_multiple_child_repos() { fs::create_dir_all(repo_a.join(".git")).unwrap(); fs::create_dir_all(repo_b.join(".git")).unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let cfg = read_codex_config(&config_path); let projects = cfg["projects"].as_table().unwrap(); @@ -297,12 +298,96 @@ fn prepare_codex_config_toml_overwrites_stale_openai_base_url() { ) .unwrap(); - prepare_codex_config_toml(&config_path, &working_dir).unwrap(); + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); let cfg = read_codex_config(&config_path); assert_eq!(cfg["openai_base_url"].as_str(), Some(CODEX_OPENAI_BASE_URL)); } +#[test] +fn prepare_codex_config_toml_writes_model_when_specified() { + // A non-default model id is written to the top-level `model` key so Codex pins it + // for new sessions launched from this `~/.codex/config.toml`. Even for the + // current target model, we stamp a self-referential migration entry so the + // upgrade prompt is suppressed regardless of what the user selected. + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("config.toml"); + let working_dir = tmp.path().join("workspace"); + fs::create_dir_all(&working_dir).unwrap(); + + prepare_codex_config_toml(&config_path, &working_dir, Some("gpt-5.5")).unwrap(); + + let cfg = read_codex_config(&config_path); + assert_eq!(cfg["model"].as_str(), Some("gpt-5.5")); + assert_eq!( + cfg["notice"]["model_migrations"]["gpt-5.5"].as_str(), + Some(CODEX_MODEL_MIGRATIONS_TARGET), + ); +} + +#[test] +fn prepare_codex_config_toml_writes_model_migration_for_older_model() { + // For an older model id, the migration entry maps it to the current target + // so Codex's "choose a newer model" prompt is suppressed at session launch. + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("config.toml"); + let working_dir = tmp.path().join("workspace"); + fs::create_dir_all(&working_dir).unwrap(); + + prepare_codex_config_toml(&config_path, &working_dir, Some("gpt-5.2")).unwrap(); + + let cfg = read_codex_config(&config_path); + assert_eq!(cfg["model"].as_str(), Some("gpt-5.2")); + assert_eq!( + cfg["notice"]["model_migrations"]["gpt-5.2"].as_str(), + Some(CODEX_MODEL_MIGRATIONS_TARGET), + ); +} + +#[test] +fn prepare_codex_config_toml_skips_model_for_default_sentinel() { + // The literal "default" sentinel means "let Codex pick its own default model"; + // we should NOT write a `model` key (or a migration entry) in that case. + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("config.toml"); + let working_dir = tmp.path().join("workspace"); + fs::create_dir_all(&working_dir).unwrap(); + + prepare_codex_config_toml(&config_path, &working_dir, Some("default")).unwrap(); + + let cfg = read_codex_config(&config_path); + assert!( + cfg.get("model").is_none(), + "`model` should not be written for the default sentinel" + ); + assert!( + cfg.get("notice").is_none(), + "`[notice]` table should not be written without a pinned model id" + ); +} + +#[test] +fn prepare_codex_config_toml_skips_model_when_none() { + // No model id supplied means the user didn't pick one; we should not write a + // `model` key or any `[notice.model_migrations]` entries. + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("config.toml"); + let working_dir = tmp.path().join("workspace"); + fs::create_dir_all(&working_dir).unwrap(); + + prepare_codex_config_toml(&config_path, &working_dir, None).unwrap(); + + let cfg = read_codex_config(&config_path); + assert!( + cfg.get("model").is_none(), + "`model` should not be written when no override is supplied" + ); + assert!( + cfg.get("notice").is_none(), + "`[notice]` table should not be written without a pinned model id" + ); +} + #[test] fn find_child_git_repos_returns_only_repo_children() { let tmp = TempDir::new().unwrap(); diff --git a/app/src/ai/agent_sdk/driver/harness/gemini.rs b/app/src/ai/agent_sdk/driver/harness/gemini.rs index c3ecda57e..d4c309ccf 100644 --- a/app/src/ai/agent_sdk/driver/harness/gemini.rs +++ b/app/src/ai/agent_sdk/driver/harness/gemini.rs @@ -51,6 +51,7 @@ impl ThirdPartyHarness for GeminiHarness { working_dir: &Path, system_prompt: Option<&str>, _secrets: &HashMap, + _third_party_harness_model_id: Option<&str>, ) -> Result<(), AgentDriverError> { prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| { AgentDriverError::HarnessConfigSetupFailed { diff --git a/app/src/ai/agent_sdk/driver/harness/mod.rs b/app/src/ai/agent_sdk/driver/harness/mod.rs index 843985d4a..c3706f2fd 100644 --- a/app/src/ai/agent_sdk/driver/harness/mod.rs +++ b/app/src/ai/agent_sdk/driver/harness/mod.rs @@ -85,6 +85,7 @@ pub(crate) trait ThirdPartyHarness: Send + Sync { _working_dir: &Path, _system_prompt: Option<&str>, _secrets: &HashMap, + _third_party_harness_model_id: Option<&str>, ) -> Result<(), AgentDriverError> { Ok(()) } @@ -317,10 +318,12 @@ pub(crate) fn task_env_vars( /// Returns an empty map for Oz or when no model is specified. pub(crate) fn harness_model_env_vars( selected_harness: Harness, - harness_model_id: Option<&str>, + third_party_harness_model_id: Option<&str>, ) -> HashMap { let mut env_vars = HashMap::new(); - let Some(model_id) = harness_model_id.filter(|id| !id.is_empty() && *id != "default") else { + let Some(model_id) = + third_party_harness_model_id.filter(|id| !id.is_empty() && *id != "default") + else { return env_vars; }; diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 8ce59a329..65eb4e1d7 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -807,7 +807,7 @@ impl AgentDriverRunner { let should_share = (args.share.is_shared() || args.task_id.is_some()) && FeatureFlag::AgentSharedSessions.is_enabled(); - let harness_model_id = merged_config + let third_party_harness_model_id = merged_config .harness .as_ref() .and_then(|h| h.model_id.clone()); @@ -822,7 +822,7 @@ impl AgentDriverRunner { cloud_providers: Vec::new(), environment: None, selected_harness: args.harness, - harness_model_id, + third_party_harness_model_id, snapshot_disabled: args.snapshot.no_snapshot.then_some(true), snapshot_upload_timeout: args .snapshot @@ -1111,8 +1111,8 @@ impl AgentDriverRunner { driver_options.parent_run_id = parent_run_id; driver_options.secrets = secrets; // CLI flags continue to take precedence so users can still override per-invocation. - if driver_options.harness_model_id.is_none() { - driver_options.harness_model_id = task_harness_model_id; + if driver_options.third_party_harness_model_id.is_none() { + driver_options.third_party_harness_model_id = task_harness_model_id; } // Update the task prompt to include the downloaded attachments dir diff --git a/app/src/pane_group/pane/local_harness_launch.rs b/app/src/pane_group/pane/local_harness_launch.rs index d1535e8c6..fa415f6f6 100644 --- a/app/src/pane_group/pane/local_harness_launch.rs +++ b/app/src/pane_group/pane/local_harness_launch.rs @@ -106,7 +106,7 @@ pub(super) async fn prepare_local_harness_child_launch( // hidden child pane. let managed_secrets: HashMap = HashMap::new(); claude_harness - .prepare_environment_config(&working_dir, None, &managed_secrets) + .prepare_environment_config(&working_dir, None, &managed_secrets, None) .map_err(|error: AgentDriverError| error.to_string())?; if let Some(manager) = plugin_manager_for(claude_harness.cli_agent()) { if let Err(error) = manager.install().await {