mirror of
https://github.com/openai/codex.git
synced 2026-10-19 08:16:48 +00:00
Use server model defaults for connected TUI fresh starts (#50913)
## Why Fresh starts connected to an app server can bootstrap with stale client model settings before reading the server configuration. An empty model catalog can also prevent startup even when managed new-thread defaults provide a model. ## What changed - Read server configuration before model bootstrap for fresh starts using a local daemon or remote server without an explicit profile. - Preserve explicit model and reasoning-effort overrides, clear implicit client settings when `config/read` is unsupported, and allow managed new-thread defaults to supply a model when the catalog is empty. - Use the launch directory for local daemon configuration reads and the server directory for remote reads. ## Testing Update startup-default tests to cover managed model selection with an empty catalog. Strengthen the reconnect test with a stale client model and an assertion that `config/read` uses the server directory. GitOrigin-RevId: 8fe1485c6e66c8d46560b34d904ce1724458993a
This commit is contained in:
@@ -261,6 +261,7 @@ mod session_lifecycle;
|
||||
mod session_picker;
|
||||
mod side;
|
||||
mod startup;
|
||||
pub(crate) mod startup_bootstrap;
|
||||
mod startup_prompts;
|
||||
mod startup_warnings;
|
||||
mod thread_event_buffer;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! Explicit local launch permissions remain runtime overrides across new sessions and reconnects.
|
||||
|
||||
use super::reconnect::ReconnectState;
|
||||
use super::startup_bootstrap::bootstrap_server_owned_start;
|
||||
use super::startup_bootstrap::uses_server_owned_fresh_bootstrap;
|
||||
use super::*;
|
||||
use crate::session_start::SessionStartAction;
|
||||
use crate::session_start::SessionStartConfig;
|
||||
@@ -56,13 +58,14 @@ pub(super) struct FreshStartupDefaults {
|
||||
pub(super) async fn prepare_fresh_startup_config(
|
||||
config: &mut Config,
|
||||
app_server: &AppServerSession,
|
||||
app_server_target: &AppServerTarget,
|
||||
cli_kv_overrides: &[(String, TomlValue)],
|
||||
harness_overrides: &ConfigOverrides,
|
||||
environments: &EnvironmentManager,
|
||||
) -> Result<FreshStartupDefaults> {
|
||||
let defaults_cwd = match app_server.thread_params_mode() {
|
||||
crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(),
|
||||
crate::app_server_session::ThreadParamsMode::Remote => {
|
||||
let defaults_cwd = match app_server_target {
|
||||
AppServerTarget::Embedded | AppServerTarget::LocalDaemon { .. } => config.cwd.as_path(),
|
||||
AppServerTarget::Remote { .. } => {
|
||||
app_server.remote_cwd_override().unwrap_or(Path::new("."))
|
||||
}
|
||||
};
|
||||
@@ -106,8 +109,8 @@ pub(super) fn startup_model(
|
||||
) -> String {
|
||||
config.model.clone().unwrap_or_else(|| {
|
||||
if server_defaults_read {
|
||||
// Bootstrap was seeded with local config, which may differ from a cleared server
|
||||
// model. Use the server's model catalog when config/read returned model: null.
|
||||
// Legacy bootstrap paths may be seeded with local config, which can differ from a
|
||||
// cleared server model. Use the server catalog when config/read returned model: null.
|
||||
bootstrap
|
||||
.available_models
|
||||
.iter()
|
||||
@@ -269,22 +272,27 @@ impl App {
|
||||
let harness_overrides =
|
||||
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
|
||||
app_server.model_provider_override = harness_overrides.model_provider.clone();
|
||||
let bootstrap = match startup_bootstrap {
|
||||
Some(bootstrap) => bootstrap,
|
||||
None => match startup_draft
|
||||
.run_until(tui, app_server.bootstrap(&config))
|
||||
.await
|
||||
{
|
||||
Ok(bootstrap) => bootstrap?,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
},
|
||||
};
|
||||
tracing::debug!(
|
||||
has_platform_family = app_server.app_server_platform_family().is_some(),
|
||||
has_platform_os = app_server.app_server_platform_os().is_some(),
|
||||
"connected app-server platform"
|
||||
let fresh_start = matches!(
|
||||
&session_selection,
|
||||
SessionSelection::StartFresh | SessionSelection::Exit
|
||||
);
|
||||
let bootstrap_ms = bootstrap.duration.as_millis();
|
||||
let server_owned_fresh_bootstrap = uses_server_owned_fresh_bootstrap(
|
||||
&app_server_target,
|
||||
&session_selection,
|
||||
&loader_overrides,
|
||||
);
|
||||
let mut bootstrap = startup_bootstrap;
|
||||
if bootstrap.is_none() && !server_owned_fresh_bootstrap {
|
||||
bootstrap = Some(
|
||||
match startup_draft
|
||||
.run_until(tui, app_server.bootstrap(&config))
|
||||
.await
|
||||
{
|
||||
Ok(bootstrap) => bootstrap?,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
},
|
||||
);
|
||||
}
|
||||
if matches!(&session_selection, SessionSelection::Fork(_)) {
|
||||
// The app server resolves omitted overrides from the fork destination's config.
|
||||
if harness_overrides.model.is_none()
|
||||
@@ -299,16 +307,14 @@ impl App {
|
||||
config.model_reasoning_effort = None;
|
||||
}
|
||||
}
|
||||
let startup_defaults = if matches!(
|
||||
&session_selection,
|
||||
SessionSelection::StartFresh | SessionSelection::Exit
|
||||
) {
|
||||
let startup_defaults = if fresh_start {
|
||||
match startup_draft
|
||||
.run_until(
|
||||
tui,
|
||||
prepare_fresh_startup_config(
|
||||
&mut config,
|
||||
&app_server,
|
||||
&app_server_target,
|
||||
&cli_kv_overrides,
|
||||
&harness_overrides,
|
||||
&environment_manager,
|
||||
@@ -323,7 +329,40 @@ impl App {
|
||||
} else {
|
||||
FreshStartupDefaults::default()
|
||||
};
|
||||
if matches!(&session_selection, SessionSelection::AgentsOverview) {
|
||||
let mut launch_choices = crate::app_server_session::StartupLaunchChoices::from_launch(
|
||||
&cli_kv_overrides,
|
||||
&harness_overrides,
|
||||
&loader_overrides,
|
||||
);
|
||||
let bootstrap = match bootstrap {
|
||||
Some(bootstrap) => bootstrap,
|
||||
None => match startup_draft
|
||||
.run_until(
|
||||
tui,
|
||||
bootstrap_server_owned_start(
|
||||
&mut app_server,
|
||||
&mut config,
|
||||
&mut launch_choices,
|
||||
startup_defaults.server_defaults_read,
|
||||
&cli_kv_overrides,
|
||||
&harness_overrides,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bootstrap) => bootstrap?,
|
||||
Err(err) => return shutdown_on_startup_error(app_server, err).await,
|
||||
},
|
||||
};
|
||||
tracing::debug!(
|
||||
has_platform_family = app_server.app_server_platform_family().is_some(),
|
||||
has_platform_os = app_server.app_server_platform_os().is_some(),
|
||||
"connected app-server platform"
|
||||
);
|
||||
let bootstrap_ms = bootstrap.duration.as_millis();
|
||||
if !server_owned_fresh_bootstrap
|
||||
&& matches!(&session_selection, SessionSelection::AgentsOverview)
|
||||
{
|
||||
apply_managed_new_thread_defaults(
|
||||
&mut config,
|
||||
app_server.managed_new_thread_defaults(),
|
||||
@@ -331,11 +370,6 @@ impl App {
|
||||
&harness_overrides,
|
||||
);
|
||||
}
|
||||
let mut launch_choices = crate::app_server_session::StartupLaunchChoices::from_launch(
|
||||
&cli_kv_overrides,
|
||||
&harness_overrides,
|
||||
&loader_overrides,
|
||||
);
|
||||
let mut model = startup_model(&config, &bootstrap, startup_defaults.server_defaults_read);
|
||||
let available_models = bootstrap.available_models;
|
||||
let remote_connection = crate::status::remote_connection::remote_connection_status_value(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Server-owned model selection for connected fresh starts.
|
||||
//!
|
||||
//! Embedded and explicit-profile starts retain legacy behavior. Unsupported `config/read` never
|
||||
//! restores an implicit local model, while managed new-thread defaults may satisfy an empty catalog.
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::StartupLaunchChoices;
|
||||
|
||||
pub(crate) fn uses_server_owned_fresh_bootstrap(
|
||||
target: &AppServerTarget,
|
||||
selection: &SessionSelection,
|
||||
loader_overrides: &LoaderOverrides,
|
||||
) -> bool {
|
||||
!matches!(target, AppServerTarget::Embedded)
|
||||
&& matches!(
|
||||
selection,
|
||||
SessionSelection::StartFresh | SessionSelection::Exit
|
||||
)
|
||||
&& loader_overrides.user_config_profile.is_none()
|
||||
}
|
||||
|
||||
pub(super) async fn bootstrap_server_owned_start(
|
||||
app_server: &mut AppServerSession,
|
||||
config: &mut Config,
|
||||
launch_choices: &mut StartupLaunchChoices,
|
||||
server_defaults_read: bool,
|
||||
cli_kv_overrides: &[(String, TomlValue)],
|
||||
harness_overrides: &ConfigOverrides,
|
||||
) -> Result<AppServerBootstrap> {
|
||||
if !server_defaults_read {
|
||||
config.model = launch_choices.model.clone();
|
||||
if launch_choices.effort.is_none() {
|
||||
config.model_reasoning_effort = None;
|
||||
}
|
||||
}
|
||||
let bootstrap = if launch_choices.model.is_none() && launch_choices.effort.is_none() {
|
||||
app_server.bootstrap_for_new_thread(config).await
|
||||
} else {
|
||||
app_server.bootstrap(config).await
|
||||
}?;
|
||||
let previous = (config.model.clone(), config.model_reasoning_effort.clone());
|
||||
apply_managed_new_thread_defaults(
|
||||
config,
|
||||
app_server.managed_new_thread_defaults(),
|
||||
cli_kv_overrides,
|
||||
harness_overrides,
|
||||
);
|
||||
if (config.model.clone(), config.model_reasoning_effort.clone()) != previous {
|
||||
launch_choices.model = config.model.clone();
|
||||
launch_choices.effort = config
|
||||
.model_reasoning_effort
|
||||
.as_ref()
|
||||
.map(|effort| serde_json::json!(effort));
|
||||
}
|
||||
Ok(bootstrap)
|
||||
}
|
||||
@@ -328,6 +328,7 @@ async fn local_projectless_defaults_respect_trust_scope_and_explicit_settings()
|
||||
let startup = crate::app::startup::prepare_fresh_startup_config(
|
||||
&mut config,
|
||||
&server,
|
||||
&AppServerTarget::Embedded,
|
||||
&[],
|
||||
&overrides,
|
||||
&EnvironmentManager::default_for_tests(),
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
use super::*;
|
||||
use crate::app::startup::prepare_fresh_startup_config;
|
||||
use crate::app::startup::startup_model;
|
||||
use crate::app::startup_bootstrap::bootstrap_server_owned_start;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn remote_target() -> AppServerTarget {
|
||||
AppServerTarget::Remote {
|
||||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url: "ws://127.0.0.1:1".into(),
|
||||
auth_token: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_startup_for_test(
|
||||
tui: &mut crate::tui::Tui,
|
||||
server: AppServerSession,
|
||||
@@ -268,7 +278,7 @@ async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence
|
||||
Some("low"),
|
||||
None,
|
||||
),
|
||||
("managed", true, None, None, None),
|
||||
("managed", true, Some("managed-model"), Some("medium"), None),
|
||||
] {
|
||||
let client_home = tempdir()?;
|
||||
let server_home = tempdir()?;
|
||||
@@ -287,7 +297,11 @@ requires_openai_auth = false
|
||||
)?;
|
||||
std::fs::write(
|
||||
server_home.path().join("config.toml"),
|
||||
"model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n",
|
||||
if choice == "managed" {
|
||||
""
|
||||
} else {
|
||||
"model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n"
|
||||
},
|
||||
)?;
|
||||
if managed {
|
||||
std::fs::write(
|
||||
@@ -341,6 +355,7 @@ requires_openai_auth = false
|
||||
let mut server_config = config.clone();
|
||||
server_config.codex_home = server_home.path().to_path_buf().abs();
|
||||
server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs());
|
||||
server_config.model_catalog = Some(Default::default());
|
||||
let (mut server, requests, proxy) = start_recording_app_server_with_history(
|
||||
&server_config,
|
||||
HistoryCapabilities::Current,
|
||||
@@ -355,18 +370,28 @@ requires_openai_auth = false
|
||||
)
|
||||
.await?;
|
||||
server = server.with_remote_cwd_override(Some(server_config.cwd.to_path_buf()));
|
||||
server.bootstrap(&config).await?;
|
||||
let target = remote_target();
|
||||
assert!(
|
||||
prepare_fresh_startup_config(
|
||||
&mut config,
|
||||
&server,
|
||||
&target,
|
||||
&cli_kv_overrides,
|
||||
&harness_overrides,
|
||||
&EnvironmentManager::default_for_tests()
|
||||
&EnvironmentManager::default_for_tests(),
|
||||
)
|
||||
.await?
|
||||
.server_defaults_read
|
||||
);
|
||||
bootstrap_server_owned_start(
|
||||
&mut server,
|
||||
&mut config,
|
||||
&mut launch_choices,
|
||||
/*server_defaults_read*/ true,
|
||||
&cli_kv_overrides,
|
||||
&harness_overrides,
|
||||
)
|
||||
.await?;
|
||||
if choice == "migration" {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
apply_accepted_model_migration(
|
||||
@@ -470,11 +495,17 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res
|
||||
server.start_thread(&config).await?;
|
||||
}
|
||||
assert!(config.config_layer_stack.is_projectless());
|
||||
let target = if remote {
|
||||
remote_target()
|
||||
} else {
|
||||
AppServerTarget::Embedded
|
||||
};
|
||||
let bootstrap = server.bootstrap(&config).await?;
|
||||
assert_eq!(bootstrap.default_model, "stale-client-model");
|
||||
let defaults_read = prepare_fresh_startup_config(
|
||||
&mut config,
|
||||
&server,
|
||||
&target,
|
||||
&[],
|
||||
&ConfigOverrides::default(),
|
||||
&EnvironmentManager::default_for_tests(),
|
||||
|
||||
@@ -180,6 +180,11 @@ pub(crate) enum ForkPermissionMode {
|
||||
OverrideFromCurrentConfig,
|
||||
}
|
||||
|
||||
enum EmptyCatalogFallback {
|
||||
Error,
|
||||
ManagedNewThread,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ForkPresentation {
|
||||
Regular,
|
||||
@@ -642,6 +647,19 @@ impl AppServerSession {
|
||||
Ok(bootstrap)
|
||||
}
|
||||
|
||||
pub(crate) async fn bootstrap_for_new_thread(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
) -> Result<AppServerBootstrap> {
|
||||
let started_at = Instant::now();
|
||||
let account = self.read_account().await?;
|
||||
let mut bootstrap = self
|
||||
.bootstrap_with_account_inner(config, account, EmptyCatalogFallback::ManagedNewThread)
|
||||
.await?;
|
||||
bootstrap.duration = started_at.elapsed();
|
||||
Ok(bootstrap)
|
||||
}
|
||||
|
||||
/// Bootstraps using a previously read account.
|
||||
///
|
||||
/// Callers must discard a prefetched account after authentication, server, or provider changes.
|
||||
@@ -649,6 +667,16 @@ impl AppServerSession {
|
||||
&mut self,
|
||||
config: &Config,
|
||||
account: GetAccountResponse,
|
||||
) -> Result<AppServerBootstrap> {
|
||||
self.bootstrap_with_account_inner(config, account, EmptyCatalogFallback::Error)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn bootstrap_with_account_inner(
|
||||
&mut self,
|
||||
config: &Config,
|
||||
account: GetAccountResponse,
|
||||
fallback: EmptyCatalogFallback,
|
||||
) -> Result<AppServerBootstrap> {
|
||||
let started_at = Instant::now();
|
||||
// `hooks/list` holds the global config queue during startup. Submit models and config
|
||||
@@ -708,6 +736,13 @@ impl AppServerSession {
|
||||
.map(|model| model.model.clone())
|
||||
})
|
||||
.or_else(|| available_models.first().map(|model| model.model.clone()))
|
||||
.or_else(|| match fallback {
|
||||
EmptyCatalogFallback::Error => None,
|
||||
EmptyCatalogFallback::ManagedNewThread => self
|
||||
.managed_new_thread_defaults
|
||||
.as_ref()
|
||||
.and_then(|defaults| defaults.model.clone()),
|
||||
})
|
||||
.wrap_err("No models are available. Set `model` explicitly or check your model catalog configuration.")?;
|
||||
self.default_model = Some(default_model.clone());
|
||||
self.available_models = available_models.clone();
|
||||
|
||||
+12
-3
@@ -1990,15 +1990,24 @@ async fn run_ratatui_app(
|
||||
let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume;
|
||||
let hooks_request_handle = app_server.request_handle();
|
||||
let hooks_cwd = config.cwd.to_path_buf();
|
||||
let server_owned_fresh_bootstrap = app::startup_bootstrap::uses_server_owned_fresh_bootstrap(
|
||||
&app_server_target,
|
||||
&session_selection,
|
||||
&loader_overrides,
|
||||
);
|
||||
let startup_prefetch_started_at = Instant::now();
|
||||
let startup_prefetch = startup_draft
|
||||
.run_until(&mut tui, async {
|
||||
tokio::join!(
|
||||
async {
|
||||
match startup_account {
|
||||
if server_owned_fresh_bootstrap {
|
||||
return Ok::<_, color_eyre::Report>(None);
|
||||
}
|
||||
let bootstrap = match startup_account {
|
||||
Some(account) => app_server.bootstrap_with_account(&config, account).await,
|
||||
None => app_server.bootstrap(&config).await,
|
||||
}
|
||||
}?;
|
||||
Ok(Some(bootstrap))
|
||||
},
|
||||
load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd),
|
||||
)
|
||||
@@ -2016,7 +2025,7 @@ async fn run_ratatui_app(
|
||||
return Err(err.into());
|
||||
}
|
||||
let startup_bootstrap = match startup_bootstrap {
|
||||
Ok(startup_bootstrap) => Some(startup_bootstrap),
|
||||
Ok(startup) => startup,
|
||||
Err(err) => {
|
||||
shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await;
|
||||
return Err(err);
|
||||
|
||||
@@ -24,7 +24,10 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
let codex_home = tempfile::tempdir_in("/tmp")?;
|
||||
write_test_config(codex_home.path(), &workspace_path)?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
let config = std::fs::read_to_string(&config_path)?.replace(
|
||||
"model = \"gpt-5.6-terra\"",
|
||||
"model = \"stale-client-model\"",
|
||||
);
|
||||
std::fs::write(
|
||||
config_path,
|
||||
format!("{config}\n[tui]\nstatus_line = [\"thread-id\"]\n"),
|
||||
@@ -115,6 +118,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
}
|
||||
"model/list" => json!({"data": [], "nextCursor": null}),
|
||||
"config/read" => {
|
||||
let params = request.params.as_ref().unwrap();
|
||||
assert_eq!(params["cwd"], server_cwd.to_string_lossy().as_ref());
|
||||
json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai",
|
||||
"tui": {"status_line": ["thread-id"]}, "projects": {
|
||||
server_cwd.to_string_lossy(): {"trust_level": "trusted"}
|
||||
|
||||
Reference in New Issue
Block a user