mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Keep threads on their saved model provider when defaults change (#59503)
## Why Changing the default model provider should not prevent users from resuming or continuing a thread that uses another configured provider. ## What changed - Resolve cold resumes using the thread's saved provider and reject explicit attempts to switch it on resume. - Use retained provider configuration for loaded threads without reloading the current default selection, while continuing to enforce managed provider policy. - Preserve errors for missing provider definitions and policy conflicts. ## Testing Update app-server and TUI coverage for resuming with a different default, recovering after restoring a missing provider definition, preserving drafts across resume retries, and continuing loaded threads with malformed system defaults. GitOrigin-RevId: c5bf3b03cacde7349480c94c2f10bac827a0eb1f
This commit is contained in:
@@ -343,16 +343,13 @@ be loaded. This covers turn start/steer, review, compaction, manual queue start,
|
||||
updates. Realtime connections use separate routing configuration and are not checked here.
|
||||
Interrupt, realtime stop, and goal pause/clear remain available.
|
||||
|
||||
Resume and input RPCs also reject a thread whose saved provider differs from the current
|
||||
configured provider, including profile and app-server launch overrides. The JSON-RPC error's
|
||||
`data` contains `ModelProviderError`: `modelProviderMismatch` has `modelProvider` and
|
||||
`currentModelProvider`, `modelProviderNotConfigured` identifies a missing provider, and
|
||||
`modelProviderPolicyConflict` identifies a mandatory-policy conflict. Clients can still use
|
||||
`thread/read` to display history and disable the composer with the error's explanation.
|
||||
Changing the resume request's provider does not bypass validation of the saved provider;
|
||||
switch the configured provider back to continue the chat. Input is also rejected when the
|
||||
current provider configuration cannot be loaded. Older clients retain their normal error
|
||||
fallback for unrecognized structured error variants.
|
||||
Resume and input use the thread's saved provider even when a different provider is the default.
|
||||
A cold resume resolves the saved provider from configuration. A loaded thread uses its retained
|
||||
provider configuration and checks current managed policy before accepting input. The JSON-RPC
|
||||
error's `data` contains `modelProviderNotConfigured` for a missing provider or
|
||||
`modelProviderPolicyConflict` for a mandatory-policy conflict. Clients can use `thread/read`
|
||||
to display history when input is blocked. A resume request that explicitly selects a
|
||||
different provider cannot change the thread's saved provider.
|
||||
|
||||
## Managed network requirements
|
||||
|
||||
|
||||
@@ -434,27 +434,6 @@ impl ConfigManager {
|
||||
Ok(CloudStartupConfig { bootstrap, config })
|
||||
}
|
||||
|
||||
/// Admits resumed threads and input only on the currently selected provider.
|
||||
pub(crate) async fn check_thread_model_provider_for_input(
|
||||
&self,
|
||||
current: &Config,
|
||||
) -> std::io::Result<()> {
|
||||
self.check_thread_model_provider(current).await?;
|
||||
let configured = self
|
||||
.load_latest_config(Some(current.cwd.to_path_buf()))
|
||||
.await?;
|
||||
if current.model_provider_id != configured.model_provider_id {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
ModelProviderError::ModelProviderMismatch {
|
||||
model_provider: current.model_provider_id.clone(),
|
||||
current_model_provider: configured.model_provider_id,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Installs local policy before cloud access; failed policy loads block traffic while
|
||||
/// non-strict startup remains available for configuration repair.
|
||||
pub(crate) async fn load_startup_config(
|
||||
@@ -544,7 +523,7 @@ impl ConfigManager {
|
||||
&self,
|
||||
saved_provider: Option<&str>,
|
||||
request_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
mut typesafe_overrides: ConfigOverrides,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> std::io::Result<Config> {
|
||||
let requested_provider = typesafe_overrides.model_provider.clone().or_else(|| {
|
||||
@@ -554,11 +533,9 @@ impl ConfigManager {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned)
|
||||
});
|
||||
// Resolve the selection before the saved provider is applied as a resume override.
|
||||
let current_provider = self
|
||||
.load_latest_config(typesafe_overrides.cwd.clone().or_else(|| cwd.clone()))
|
||||
.await
|
||||
.map(|config| config.model_provider_id);
|
||||
if let Some(saved) = saved_provider {
|
||||
typesafe_overrides.model_provider = Some(saved.to_owned());
|
||||
}
|
||||
self.load_with_validation(
|
||||
&self.current_cli_overrides(),
|
||||
request_overrides,
|
||||
@@ -567,6 +544,7 @@ impl ConfigManager {
|
||||
|config, _overrides, layers| {
|
||||
let requested_provider = requested_provider
|
||||
.as_deref()
|
||||
.or(saved_provider)
|
||||
.or(config.model_provider.as_deref())
|
||||
.unwrap_or("openai");
|
||||
// Validate the saved route even when a client sends a provider override.
|
||||
@@ -597,20 +575,13 @@ impl ConfigManager {
|
||||
));
|
||||
}
|
||||
}
|
||||
let current_provider = current_provider?;
|
||||
for provider in [
|
||||
saved_provider.unwrap_or(requested_provider),
|
||||
requested_provider,
|
||||
] {
|
||||
if provider != current_provider {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
ModelProviderError::ModelProviderMismatch {
|
||||
model_provider: provider.to_owned(),
|
||||
current_model_provider: current_provider,
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(saved) = saved_provider
|
||||
&& saved != requested_provider
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("Cannot change this thread's model provider from `{saved}` to `{requested_provider}` on resume."),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|
||||
@@ -261,62 +261,3 @@ async fn catalog_reload_uses_latest_model_provider() -> Result<()> {
|
||||
assert_eq!(refreshed.model_provider_id, "openai");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invocation overrides and selected profiles still define the current provider.
|
||||
#[test_case("model_provider", "gateway"; "explicit_provider")]
|
||||
#[test_case("profile", "gateway-profile"; "selected_profile")]
|
||||
#[tokio::test]
|
||||
async fn input_provider_check_uses_effective_selection(key: &str, value: &str) -> Result<()> {
|
||||
let home = tempdir()?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
r#"
|
||||
model_provider = "openai"
|
||||
[model_providers.gateway]
|
||||
name = "Gateway"
|
||||
"#,
|
||||
)?;
|
||||
let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let cli_overrides = if key == "profile" {
|
||||
std::fs::write(
|
||||
home.path().join(format!("{value}.config.toml")),
|
||||
"model_provider = 'gateway'\n[model_providers.gateway]\nname = 'Gateway'\n",
|
||||
)?;
|
||||
loader_overrides.user_config_path = Some(AbsolutePathBuf::from_absolute_path(
|
||||
home.path().join(format!("{value}.config.toml")),
|
||||
)?);
|
||||
loader_overrides.user_config_profile = Some(value.parse()?);
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![(key.into(), TomlValue::String(value.into()))]
|
||||
};
|
||||
let manager = ConfigManager::new_for_tests(
|
||||
home.path().to_path_buf(),
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
let current = manager
|
||||
.load_latest_config(Some(home.path().to_path_buf()))
|
||||
.await?;
|
||||
assert_eq!(current.model_provider_id, "gateway");
|
||||
manager
|
||||
.check_thread_model_provider_for_input(¤t)
|
||||
.await?;
|
||||
let default_manager =
|
||||
ConfigManager::without_managed_config_for_tests(home.path().to_path_buf());
|
||||
let error = default_manager
|
||||
.check_thread_model_provider_for_input(¤t)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error
|
||||
.get_ref()
|
||||
.and_then(|error| error.downcast_ref::<ModelProviderError>()),
|
||||
Some(&ModelProviderError::ModelProviderMismatch {
|
||||
model_provider: "gateway".into(),
|
||||
current_model_provider: "openai".into(),
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ impl ThreadGoalRequestProcessor {
|
||||
&& let Ok(thread) = self.thread_manager.get_thread(thread_id).await
|
||||
{
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
}
|
||||
|
||||
@@ -2608,7 +2608,7 @@ impl ThreadRequestProcessor {
|
||||
let (_, thread) = self.load_thread(&thread_id).await?;
|
||||
ensure_direct_input_allowed(thread.as_ref()).await?;
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
self.submit_core_op(request_id, thread.as_ref(), Op::Compact)
|
||||
@@ -4790,7 +4790,7 @@ impl ThreadRequestProcessor {
|
||||
);
|
||||
}
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(existing_thread.config().await.as_ref())
|
||||
.check_thread_model_provider(existing_thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
let redact_resume_payloads =
|
||||
|
||||
@@ -193,7 +193,7 @@ impl ThreadQueueRequestProcessor {
|
||||
let thread = loaded_thread
|
||||
.ok_or_else(|| invalid_request("resume the thread before starting a queued message"))?;
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
let submission = self
|
||||
|
||||
@@ -194,7 +194,7 @@ impl TurnRequestProcessor {
|
||||
self.ensure_direct_input_allowed(request_id, thread.as_ref())
|
||||
.await?;
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
match self
|
||||
@@ -389,7 +389,7 @@ impl TurnRequestProcessor {
|
||||
self.ensure_direct_input_allowed(request_id, thread.as_ref())
|
||||
.await?;
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
let (review_request, display_text) = Self::review_request_from_target(params.target)?;
|
||||
@@ -559,7 +559,7 @@ impl TurnRequestProcessor {
|
||||
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
|
||||
.await?;
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
if let Some(tool_output) = ¶ms.tool_output {
|
||||
@@ -1095,7 +1095,7 @@ impl TurnRequestProcessor {
|
||||
self.ensure_direct_input_allowed(request_id, thread.as_ref())
|
||||
.await?;
|
||||
self.config_manager
|
||||
.check_thread_model_provider_for_input(thread.config().await.as_ref())
|
||||
.check_thread_model_provider(thread.config().await.as_ref())
|
||||
.await
|
||||
.map_err(|error| config_load_error(&error))?;
|
||||
|
||||
|
||||
@@ -1010,14 +1010,12 @@ async fn resume_revalidates_persisted_thread_after_config_load(
|
||||
(resumed.thread.git_info, resumed.thread.project_id),
|
||||
(expected_git_info.clone(), expected_project_id.clone())
|
||||
);
|
||||
// Each resume attempt loads the current provider selection and then the
|
||||
// saved thread config. Changed metadata must trigger another full attempt.
|
||||
if matches!(mutation, ResumeMutation::Unchanged) {
|
||||
assert_eq!(resume_config_loads, 2);
|
||||
assert_eq!(resume_config_loads, 1);
|
||||
} else {
|
||||
assert!(
|
||||
resume_config_loads >= 4,
|
||||
"changed persisted metadata must reload both provider and thread config"
|
||||
resume_config_loads >= 2,
|
||||
"changed persisted metadata must reload config"
|
||||
);
|
||||
}
|
||||
turn_result?;
|
||||
|
||||
@@ -4,12 +4,14 @@ use anyhow::Result;
|
||||
use app_test_support::MockResponsesConfig;
|
||||
use app_test_support::TestAppServer;
|
||||
use codex_app_server::in_process;
|
||||
use codex_app_server::in_process::InProcessServerEvent;
|
||||
use codex_app_server::in_process::InProcessStartArgs;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadQueueAddParams;
|
||||
use codex_app_server_protocol::ThreadQueueAddResponse;
|
||||
use codex_app_server_protocol::ThreadQueueListParams;
|
||||
@@ -42,7 +44,6 @@ use wiremock::MockServer;
|
||||
|
||||
#[test_case("other"; "selection_changes")]
|
||||
#[test_case("gateway"; "definition_changes")]
|
||||
#[test_case("default"; "default_selection_changes")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn provider_requirement_changes_reject_inputs_to_existing_threads(
|
||||
required_selection: &str,
|
||||
@@ -79,17 +80,7 @@ base_url = "{}/v1"
|
||||
other.uri(),
|
||||
);
|
||||
let requirements_path = home.path().join("requirements.toml");
|
||||
if required_selection == "default" {
|
||||
std::fs::write(
|
||||
&requirements_path,
|
||||
requirements.replace("model_provider = \"gateway\"", ""),
|
||||
)?;
|
||||
let path = home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&path)?;
|
||||
std::fs::write(path, config.replace("mock_provider", "gateway"))?;
|
||||
} else {
|
||||
std::fs::write(&requirements_path, &requirements)?;
|
||||
}
|
||||
std::fs::write(&requirements_path, &requirements)?;
|
||||
let mut server = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.with_json_logging("warn")
|
||||
@@ -136,24 +127,12 @@ base_url = "{}/v1"
|
||||
} else {
|
||||
requirements.replace(gateway.uri(), &other.uri())
|
||||
};
|
||||
let provider_error = if required_selection == "default" {
|
||||
let path = home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&path)?;
|
||||
std::fs::write(
|
||||
path,
|
||||
config.replace("model_provider = \"gateway\"", "model_provider = \"other\""),
|
||||
)?;
|
||||
codex_app_server_protocol::ModelProviderError::ModelProviderMismatch {
|
||||
model_provider: "gateway".into(),
|
||||
current_model_provider: "other".into(),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(requirements_path, updated)?;
|
||||
std::fs::write(requirements_path, updated)?;
|
||||
let provider_error =
|
||||
codex_app_server_protocol::ModelProviderError::ModelProviderPolicyConflict {
|
||||
model_provider: "gateway".into(),
|
||||
required_model_provider: required_selection.into(),
|
||||
}
|
||||
};
|
||||
};
|
||||
let expected_error = JSONRPCErrorError {
|
||||
code: -32600,
|
||||
message: provider_error.to_string(),
|
||||
@@ -287,71 +266,7 @@ base_url = "{}/v1"
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn provider_selection_can_be_restored_for_existing_threads() -> Result<()> {
|
||||
let provider = MockServer::start().await;
|
||||
let home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&provider.uri()).write(home.path())?;
|
||||
let path = home.path().join("config.toml");
|
||||
let original = std::fs::read_to_string(&path)?;
|
||||
let mut server = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.build_initialized()
|
||||
.await?;
|
||||
let started = server.start_thread(ThreadStartParams::default()).await?;
|
||||
let requests = responses::mount_sse_once(
|
||||
&provider,
|
||||
responses::sse(vec![responses::ev_completed("restored")]),
|
||||
)
|
||||
.await;
|
||||
std::fs::write(
|
||||
&path,
|
||||
original.replace(
|
||||
"model_provider = \"mock_provider\"",
|
||||
"model_provider = \"openai\"",
|
||||
),
|
||||
)?;
|
||||
let params = TurnStartParams {
|
||||
thread_id: started.thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "Continue on the original provider.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let id = server.send_turn_start_request(params.clone()).await?;
|
||||
let error = server
|
||||
.read_stream_until_error_message(RequestId::Integer(id))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
error.error.data,
|
||||
Some(serde_json::to_value(
|
||||
codex_app_server_protocol::ModelProviderError::ModelProviderMismatch {
|
||||
model_provider: "mock_provider".into(),
|
||||
current_model_provider: "openai".into(),
|
||||
}
|
||||
)?)
|
||||
);
|
||||
assert!(requests.requests().is_empty());
|
||||
let id = server
|
||||
.send_raw_request(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": started.thread.id, "includeTurns": false})),
|
||||
)
|
||||
.await?;
|
||||
let read: codex_app_server_protocol::ThreadReadResponse = server.read_response(id).await?;
|
||||
assert_eq!(read.thread.model_provider, "mock_provider");
|
||||
std::fs::write(path, original)?;
|
||||
let id = server.send_turn_start_request(params).await?;
|
||||
let _: TurnStartResponse = server.read_response(id).await?;
|
||||
server
|
||||
.read_stream_until_notification_message("turn/completed")
|
||||
.await?;
|
||||
assert_eq!(requests.requests().len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn malformed_system_defaults_reject_input_until_provider_can_be_resolved() -> Result<()> {
|
||||
async fn malformed_system_defaults_do_not_block_existing_thread_turn() -> Result<()> {
|
||||
let provider = MockServer::start().await;
|
||||
let home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&provider.uri()).write(home.path())?;
|
||||
@@ -366,7 +281,7 @@ async fn malformed_system_defaults_reject_input_until_provider_can_be_resolved()
|
||||
.build()
|
||||
.await?,
|
||||
);
|
||||
let client = in_process::start(InProcessStartArgs {
|
||||
let mut client = in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config,
|
||||
cli_overrides: Vec::new(),
|
||||
@@ -421,9 +336,23 @@ async fn malformed_system_defaults_reject_input_until_provider_can_be_resolved()
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect_err("cannot verify the selected provider from malformed configuration");
|
||||
assert_eq!(response.code, -32600);
|
||||
assert!(requests.requests().is_empty());
|
||||
.expect("turn/start should succeed");
|
||||
let _: TurnStartResponse = serde_json::from_value(response)?;
|
||||
timeout(Duration::from_secs(/*secs*/ 60), async {
|
||||
loop {
|
||||
let Some(event) = client.next_event().await else {
|
||||
anyhow::bail!("app-server stopped before turn/completed");
|
||||
};
|
||||
if let InProcessServerEvent::ServerNotification(notification) = event
|
||||
&& let ServerNotification::TurnCompleted(completed) = notification.as_ref()
|
||||
&& completed.thread_id == started.thread.id
|
||||
{
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert_eq!(requests.requests().len(), 1);
|
||||
client.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6842,6 +6842,17 @@ async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result<Ro
|
||||
})
|
||||
}
|
||||
|
||||
fn create_saved_gateway_rollout(home: &Path) -> Result<String> {
|
||||
create_fake_rollout(
|
||||
home,
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved history",
|
||||
Some("old_provider"),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test_case::test_case(false, false; "missing_definition_config_override")]
|
||||
#[test_case::test_case(false, true; "missing_definition_typed_override")]
|
||||
#[test_case::test_case(true, false; "policy_conflict_config_override")]
|
||||
@@ -6854,14 +6865,7 @@ async fn thread_resume_returns_provider_error_even_with_provider_override(
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let home = TempDir::new()?;
|
||||
mock_responses_config(&server.uri()).write(home.path())?;
|
||||
let thread_id = create_fake_rollout(
|
||||
home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved history",
|
||||
Some("old_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let thread_id = create_saved_gateway_rollout(home.path())?;
|
||||
if policy_conflict {
|
||||
let config_path = home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
@@ -6963,34 +6967,12 @@ async fn thread_resume_returns_provider_error_even_with_provider_override(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(false; "missing_definition")]
|
||||
#[test_case::test_case(true; "provider_mismatch")]
|
||||
#[tokio::test]
|
||||
async fn thread_resume_recovers_after_provider_configuration_is_restored(
|
||||
configured: bool,
|
||||
) -> Result<()> {
|
||||
async fn thread_resume_recovers_after_provider_configuration_is_restored() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let home = TempDir::new()?;
|
||||
mock_responses_config(&server.uri()).write(home.path())?;
|
||||
let thread_id = create_fake_rollout(
|
||||
home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved history",
|
||||
Some("old_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let config_path = home.path().join("config.toml");
|
||||
if configured {
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
let server_uri = server.uri();
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"{config}\n[model_providers.old_provider]\nname = 'Original provider'\nbase_url = '{server_uri}/v1'\n"
|
||||
),
|
||||
)?;
|
||||
}
|
||||
let thread_id = create_saved_gateway_rollout(home.path())?;
|
||||
let mut client = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.build_initialized()
|
||||
@@ -7008,54 +6990,27 @@ async fn thread_resume_recovers_after_provider_configuration_is_restored(
|
||||
.error
|
||||
.data
|
||||
.as_ref()
|
||||
.expect("provider error should include structured data")["type"],
|
||||
if configured {
|
||||
"modelProviderMismatch"
|
||||
} else {
|
||||
"modelProviderNotConfigured"
|
||||
}
|
||||
.and_then(|data| data["type"].as_str()),
|
||||
Some("modelProviderNotConfigured")
|
||||
);
|
||||
if configured {
|
||||
for overrides in [
|
||||
ThreadResumeParams {
|
||||
model_provider: Some("mock_provider".into()),
|
||||
..params.clone()
|
||||
},
|
||||
ThreadResumeParams {
|
||||
config: Some(std::collections::HashMap::from([(
|
||||
"model_provider".into(),
|
||||
json!("mock_provider"),
|
||||
)])),
|
||||
..params.clone()
|
||||
},
|
||||
] {
|
||||
let id = client.send_thread_resume_request(overrides).await?;
|
||||
let overridden = client
|
||||
.read_stream_until_error_message(RequestId::Integer(id))
|
||||
.await?;
|
||||
assert_eq!(overridden.error, error.error);
|
||||
}
|
||||
}
|
||||
let id = client
|
||||
.send_raw_request(
|
||||
"thread/read",
|
||||
Some(json!({"threadId": params.thread_id, "includeTurns": true})),
|
||||
)
|
||||
.await?;
|
||||
let read: codex_app_server_protocol::ThreadReadResponse = client.read_response(id).await?;
|
||||
assert_eq!(read.thread.model_provider, "old_provider");
|
||||
|
||||
let config_path = home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
let restored = if configured {
|
||||
config.replace(
|
||||
"model_provider = \"mock_provider\"",
|
||||
"model_provider = \"old_provider\"",
|
||||
)
|
||||
} else {
|
||||
config.replace("mock_provider", "old_provider")
|
||||
};
|
||||
std::fs::write(config_path, restored)?;
|
||||
let id = client.send_thread_resume_request(params).await?;
|
||||
let server_uri = server.uri();
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"{config}\n[model_providers.old_provider]\nname = 'Original provider'\nbase_url = '{server_uri}/v1'\n"
|
||||
),
|
||||
)?;
|
||||
let id = client
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
model: Some("gpt-5.2-codex".into()),
|
||||
..params
|
||||
})
|
||||
.await?;
|
||||
let resumed: ThreadResumeResponse = client.read_response(id).await?;
|
||||
assert_eq!(resumed.model_provider, "old_provider");
|
||||
assert_eq!(resumed.model, "gpt-5.2-codex");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2057,117 +2057,106 @@ async fn provider_resume_retries_via_picker_preserve_fresh_errors_and_drafts() -
|
||||
}
|
||||
|
||||
async fn check_provider_resume_retries(reopen_via: ProviderResumeRoute) -> Result<()> {
|
||||
for provider_configured in [false, true] {
|
||||
let mut app = Box::pin(make_test_app()).await;
|
||||
let home = app.config.codex_home.clone();
|
||||
let definition = "[model_providers.missing_provider]\nname = 'Saved provider'\nbase_url = 'http://127.0.0.1:12345/v1'\nwire_api = 'responses'\n";
|
||||
let config = if provider_configured {
|
||||
format!("[tui]\nresume_cwd = 'current'\n{definition}")
|
||||
} else {
|
||||
"[tui]\nresume_cwd = 'current'\n".to_string()
|
||||
};
|
||||
std::fs::write(home.join("config.toml"), config)?;
|
||||
crate::legacy_core::config::set_project_trust_level(
|
||||
let mut app = Box::pin(make_test_app()).await;
|
||||
let home = app.config.codex_home.clone();
|
||||
let definition = "[model_providers.missing_provider]\nname = 'Saved provider'\nbase_url = 'http://127.0.0.1:12345/v1'\nwire_api = 'responses'\n";
|
||||
let config = "[tui]\nresume_cwd = 'current'\n";
|
||||
std::fs::write(home.join("config.toml"), config)?;
|
||||
crate::legacy_core::config::set_project_trust_level(
|
||||
home.as_path(),
|
||||
app.config.cwd.as_path(),
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let thread_id = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
home.as_path(),
|
||||
app.config.cwd.as_path(),
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved provider history",
|
||||
Some("missing_provider"),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let thread_id = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
home.as_path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved provider history",
|
||||
Some("missing_provider"),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?,
|
||||
)?;
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let target = crate::resume_picker::SessionTarget {
|
||||
backend_id: server.backend_id(),
|
||||
path: None,
|
||||
thread_id,
|
||||
cwd: None,
|
||||
history_mode: None,
|
||||
};
|
||||
Box::pin(app.resume_target_session(&mut tui, &mut server, target.clone())).await?;
|
||||
assert!(app.chat_widget.has_provider_error());
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
let turns = app.thread_event_channels[&thread_id]
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.snapshot()
|
||||
.turns;
|
||||
assert!(serde_json::to_string(&turns)?.contains("Saved provider history"));
|
||||
assert!(!app.ignore_same_thread_resume(&target));
|
||||
app.chat_widget
|
||||
.apply_external_edit("Preserved draft".into());
|
||||
for current_provider in ["other_provider", "missing_provider"] {
|
||||
if !matches!(reopen_via, ProviderResumeRoute::SameThread) {
|
||||
let other_id = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
home.as_path(),
|
||||
"2025-01-05T13-00-00",
|
||||
"2025-01-05T13:00:00Z",
|
||||
"Other chat",
|
||||
Some("openai"),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("other chat"),
|
||||
)?;
|
||||
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, other_id))
|
||||
.await?;
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(other_id));
|
||||
assert!(
|
||||
app.agents_overview.input_states[&thread_id]
|
||||
.provider_error
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!(
|
||||
"model_provider = '{current_provider}'\n[tui]\nresume_cwd = 'current'\n{definition}\n{}",
|
||||
definition.replace("missing_provider", "other_provider")
|
||||
),
|
||||
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?,
|
||||
)?;
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let target = crate::resume_picker::SessionTarget {
|
||||
backend_id: server.backend_id(),
|
||||
path: None,
|
||||
thread_id,
|
||||
cwd: None,
|
||||
history_mode: None,
|
||||
};
|
||||
Box::pin(app.resume_target_session(&mut tui, &mut server, target.clone())).await?;
|
||||
assert!(app.chat_widget.has_provider_error());
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
let turns = app.thread_event_channels[&thread_id]
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.snapshot()
|
||||
.turns;
|
||||
assert!(serde_json::to_string(&turns)?.contains("Saved provider history"));
|
||||
assert!(!app.ignore_same_thread_resume(&target));
|
||||
app.chat_widget
|
||||
.apply_external_edit("Preserved draft".into());
|
||||
for provider_configured in [false, true] {
|
||||
if !matches!(reopen_via, ProviderResumeRoute::SameThread) {
|
||||
let other_id = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
home.as_path(),
|
||||
"2025-01-05T13-00-00",
|
||||
"2025-01-05T13:00:00Z",
|
||||
"Other chat",
|
||||
Some("openai"),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("other chat"),
|
||||
)?;
|
||||
match reopen_via {
|
||||
ProviderResumeRoute::Overview => {
|
||||
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id))
|
||||
.await?;
|
||||
}
|
||||
ProviderResumeRoute::Picker => {
|
||||
Box::pin(app.apply_resume_picker_selection(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
SessionSelection::Resume(target.clone()),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
ProviderResumeRoute::SameThread => {
|
||||
Box::pin(app.resume_target_session(&mut tui, &mut server, target.clone()))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let input = app.chat_widget.capture_thread_input_state().unwrap();
|
||||
let expected_error = (current_provider != "missing_provider").then(|| {
|
||||
codex_app_server_protocol::ModelProviderError::ModelProviderMismatch {
|
||||
model_provider: "missing_provider".into(),
|
||||
current_model_provider: current_provider.into(),
|
||||
}
|
||||
});
|
||||
assert_eq!(input.provider_error, expected_error);
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Preserved draft"
|
||||
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, other_id)).await?;
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(other_id));
|
||||
assert!(
|
||||
app.agents_overview.input_states[&thread_id]
|
||||
.provider_error
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
server.shutdown().await?;
|
||||
let saved_definition = if provider_configured { definition } else { "" };
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!("model_provider = 'ollama'\n{config}{saved_definition}"),
|
||||
)?;
|
||||
match reopen_via {
|
||||
ProviderResumeRoute::Overview => {
|
||||
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id))
|
||||
.await?;
|
||||
}
|
||||
ProviderResumeRoute::Picker => {
|
||||
Box::pin(app.apply_resume_picker_selection(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
SessionSelection::Resume(target.clone()),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
ProviderResumeRoute::SameThread => {
|
||||
Box::pin(app.resume_target_session(&mut tui, &mut server, target.clone())).await?;
|
||||
}
|
||||
}
|
||||
let input = app.chat_widget.capture_thread_input_state().unwrap();
|
||||
let expected_error = (!provider_configured).then(|| {
|
||||
codex_app_server_protocol::ModelProviderError::ModelProviderNotConfigured {
|
||||
model_provider: "missing_provider".into(),
|
||||
}
|
||||
});
|
||||
assert_eq!(input.provider_error, expected_error);
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Preserved draft"
|
||||
);
|
||||
}
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ requires_openai_auth = false
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_discovers_other_providers_but_rejects_mismatched_resume() -> Result<()> {
|
||||
async fn resume_discovers_and_reopens_the_saved_gateway() -> Result<()> {
|
||||
let home = tempfile::tempdir()?;
|
||||
write_provider_config(home.path())?;
|
||||
let original = ConfigBuilder::default()
|
||||
@@ -250,18 +250,15 @@ async fn resume_discovers_other_providers_but_rejects_mismatched_resume() -> Res
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
let error = server
|
||||
let resumed = server
|
||||
.resume_thread(
|
||||
&local_settings,
|
||||
config.clone(),
|
||||
parent,
|
||||
ResumeModelSettings::RestoreFromThread,
|
||||
)
|
||||
.await
|
||||
.expect_err("a different configured provider must block resume");
|
||||
assert!(error.to_string().contains(
|
||||
"This chat uses server-provider, but your current provider is client-provider. Switch to server-provider to continue."
|
||||
));
|
||||
.await?;
|
||||
assert_eq!(resumed.session.model_provider_id, "server-provider");
|
||||
assert_eq!(
|
||||
server
|
||||
.start_thread(&config)
|
||||
|
||||
@@ -94,7 +94,13 @@ async fn resume_lists_all_providers_and_fork_keeps_provider_filter() -> Result<(
|
||||
}
|
||||
let case = format!("{action}: embedded={embedded}, last={last}, explicit={explicit}");
|
||||
let mut terminal = PtyCodex::start_cli(&cwd, home, &args)?;
|
||||
if action == "resume" {
|
||||
if action == "resume" && last && explicit {
|
||||
// --last finds the newest thread across providers. An explicit override
|
||||
// cannot switch that thread from server-provider to openai.
|
||||
terminal
|
||||
.wait_for_screen("Cannot change this thread's model provider")
|
||||
.with_context(|| format!("{case}; screen:\n{}", terminal.screen_contents()))?;
|
||||
} else if action == "resume" {
|
||||
terminal
|
||||
.wait_for_screen("Server provider history")
|
||||
.with_context(|| format!("{case}; screen:\n{}", terminal.screen_contents()))?;
|
||||
|
||||
Reference in New Issue
Block a user