Expose model settings in app-server thread metadata (#42151)

## What changed

- Add nullable `model` and `reasoningEffort` fields to the shared app-server `Thread` object and generated schemas.
- Report current settings for loaded threads and the latest persisted settings for unloaded threads across thread read, list, start, resume, rollback, metadata update, and notification paths.
- Keep unavailable legacy or filesystem-only settings nullable, and allow metadata reads without loading a thread or dispatching queued work.

## Testing

- Cover live-setting precedence, persisted settings after restart, null legacy values, and model metadata returned by thread lifecycle operations.

GitOrigin-RevId: 22ebe8a8d1910a820d90a97730d812a00d8c1372
This commit is contained in:
Brent Traut authored and copyberry committed 2026-09-01 22:56:18 +00:00
1 parent 6b59cefcbb
commit 986ff1cc7c
50 files changed
+633 -33

No files matched your search

@@ -51,7 +51,9 @@ use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::fs;
use std::fs::FileTimes;
use std::fs::OpenOptions;
@@ -1389,19 +1391,28 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result
}
let mut mcp = init_mcp(codex_home.path()).await?;
let mut loaded_settings = HashMap::new();
for (thread_id, source, capability) in threads_to_resume {
let (model, effort) = if thread_id == cli_id {
("gpt-5.2", "high")
} else {
("gpt-5.4", "low")
};
let request_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
model: Some(model.to_string()),
config: Some([("model_reasoning_effort".to_string(), json!(effort))].into()),
..Default::default()
})
.await?;
let ThreadResumeResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
(thread.id, thread.source, thread.can_accept_direct_input),
(thread_id, source, capability)
(&thread.id, &thread.source, thread.can_accept_direct_input),
(&thread_id, &source, capability)
);
loaded_settings.insert(thread_id, (thread.model, thread.reasoning_effort));
}
let response = list_threads(
@@ -1416,6 +1427,16 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result
/*archived*/ None,
)
.await?;
for thread in &response.data {
let expected_settings = loaded_settings
.get(&thread.id)
.map(|(model, effort)| (model.as_deref(), effort.clone()))
.unwrap_or((None, None));
assert_eq!(
(thread.model.as_deref(), thread.reasoning_effort.clone()),
expected_settings
);
}
expected.reverse();
assert_eq!(
response
@@ -33,6 +33,7 @@ use codex_features::Feature;
use codex_git_utils::GitSha;
use codex_protocol::SanitizedGitUrl;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::GitInfo as RolloutGitInfo;
use codex_rollout::state_db::reconcile_rollout;
use codex_state::PINNED_THREAD_SECTION_ID;
@@ -41,6 +42,7 @@ use codex_state::StateRuntime;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::path::Path;
use std::sync::Arc;
@@ -961,6 +963,8 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary(
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
model: Some("gpt-5.2".to_string()),
config: Some([("model_reasoning_effort".to_string(), json!("high"))].into()),
..Default::default()
})
.await?;
@@ -995,6 +999,10 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary(
assert_eq!(updated.id, thread_id);
assert_eq!(updated.preview, preview);
assert_eq!(updated.created_at, 1736152200);
assert_eq!(
(updated.model.as_deref(), updated.reasoning_effort),
(Some("gpt-5.2"), Some(ReasoningEffort::High))
);
assert_eq!(
updated.git_info,
Some(GitInfo {
@@ -19,6 +19,9 @@ use codex_app_server_protocol::QueuedSubmission;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadQueueAddParams;
use codex_app_server_protocol::ThreadQueueAddResponse;
use codex_app_server_protocol::ThreadQueueChangedNotification;
@@ -32,10 +35,16 @@ use codex_app_server_protocol::ThreadQueueStartParams;
use codex_app_server_protocol::ThreadQueueStartResponse;
use codex_app_server_protocol::ThreadQueueUpdateParams;
use codex_app_server_protocol::ThreadQueueUpdateResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
@@ -43,6 +52,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::skip_if_remote;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -440,6 +450,21 @@ async fn cold_thread_resume_dispatches_a_persisted_queued_submission() -> Result
.await?;
let _: TurnCompletedNotification =
timeout(READ_TIMEOUT, first.read_notification("turn/completed")).await??;
let update_id = first
.send_thread_settings_update_request(ThreadSettingsUpdateParams {
thread_id: thread_id.clone(),
model: Some("gpt-5.2".to_string()),
effort: Some(ReasoningEffort::High),
..Default::default()
})
.await?;
let _: ThreadSettingsUpdateResponse =
timeout(READ_TIMEOUT, first.read_response(update_id)).await??;
let _: ThreadSettingsUpdatedNotification = timeout(
READ_TIMEOUT,
first.read_notification("thread/settings/updated"),
)
.await??;
drop(first);
let mut resumed = TestAppServer::builder()
@@ -455,10 +480,52 @@ async fn cold_thread_resume_dispatches_a_persisted_queued_submission() -> Result
},
)
.await?;
let read_id = resumed
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.clone(),
include_turns: false,
})
.await?;
let read: ThreadReadResponse = timeout(READ_TIMEOUT, resumed.read_response(read_id)).await??;
assert_eq!(
(read.thread.model.as_deref(), read.thread.reasoning_effort),
(Some("gpt-5.2"), Some(ReasoningEffort::High))
);
assert_eq!(read.thread.status, ThreadStatus::NotLoaded);
for use_state_db_only in [false, true] {
let list_id = resumed
.send_raw_request(
"thread/list",
Some(json!({ "useStateDbOnly": use_state_db_only })),
)
.await?;
let listed: ThreadListResponse =
timeout(READ_TIMEOUT, resumed.read_response(list_id)).await??;
let listed = listed
.data
.iter()
.find(|listed| listed.id == thread_id)
.expect("persisted thread should be listed");
assert_eq!(
(listed.model.as_deref(), listed.reasoning_effort.clone()),
(Some("gpt-5.2"), Some(ReasoningEffort::High))
);
assert_eq!(listed.status, ThreadStatus::NotLoaded);
}
let loaded_id = resumed
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
.await?;
let loaded: ThreadLoadedListResponse =
timeout(READ_TIMEOUT, resumed.read_response(loaded_id)).await??;
assert!(
loaded.data.is_empty(),
"metadata reads must not resume threads"
);
assert_eq!(
list_queue(&mut resumed, &thread_id).await?.data,
vec![queued]
);
let request_id = resumed
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
@@ -135,8 +135,13 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> {
include_turns: false,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let response: Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(response["thread"].get("model"), Some(&Value::Null));
assert_eq!(
response["thread"].get("reasoningEffort"),
Some(&Value::Null)
);
let ThreadReadResponse { thread, .. } = serde_json::from_value(response)?;
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
@@ -150,6 +155,17 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> {
assert_eq!(thread.turns.len(), 0);
assert_eq!(thread.status, ThreadStatus::NotLoaded);
let list_id = mcp.send_raw_request("thread/list", Some(json!({}))).await?;
let response: Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??;
let listed = response["data"]
.as_array()
.expect("thread list")
.iter()
.find(|listed| listed["id"] == conversation_id)
.expect("stored thread should be listed");
assert_eq!(listed.get("model"), Some(&Value::Null));
assert_eq!(listed.get("reasoningEffort"), Some(&Value::Null));
Ok(())
}
@@ -22,8 +22,10 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -198,6 +200,27 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
drop(mcp);
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
model: Some("gpt-5.2".to_string()),
config: Some([("model_reasoning_effort".to_string(), json!("high"))].into()),
..Default::default()
})
.await?;
let ThreadResumeResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
// Drain the resume snapshots before checking rollback notification ordering.
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/goal/cleared"),
)
.await??;
mcp.clear_message_buffer();
// Roll back the last turn.
@@ -253,6 +276,13 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
assert_eq!(rolled_back_thread.turns.len(), 1);
assert_eq!(rolled_back_thread.status, ThreadStatus::Idle);
assert_eq!(
(
rolled_back_thread.model.as_deref(),
rolled_back_thread.reasoning_effort
),
(Some("gpt-5.2"), Some(ReasoningEffort::High))
);
assert_eq!(rolled_back_thread.turns[0].items.len(), 2);
match &rolled_back_thread.turns[0].items[0] {
ThreadItem::UserMessage { content, .. } => {
@@ -8,6 +8,7 @@ use app_test_support::write_models_cache;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
@@ -15,14 +16,23 @@ use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -50,7 +60,14 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() ->
&mut mcp,
ThreadSettingsUpdateParams {
thread_id: thread.id.clone(),
model: Some(model_id.clone()),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: model_id.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
service_tier: Some(Some(service_tier_id.clone())),
..Default::default()
},
@@ -77,8 +94,65 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() ->
)
.await??;
let read = read_thread_with_turns(&mut mcp, &thread.id).await?;
assert_eq!(read.thread.turns.len(), 1);
// Loaded metadata must come from live settings, even if stored metadata is stale.
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".to_string(),
)
.await?;
let mut stored = state_db
.get_thread(codex_protocol::ThreadId::from_string(&thread.id)?)
.await?
.expect("completed thread should be persisted");
stored.model = Some("stored-model".to_string());
stored.reasoning_effort = Some(ReasoningEffort::Low);
state_db.upsert_thread(&stored).await?;
let unsubscribe_id = mcp
.send_thread_unsubscribe_request(ThreadUnsubscribeParams {
thread_id: thread.id.clone(),
})
.await?;
let unsubscribed: ThreadUnsubscribeResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(unsubscribe_id)).await??;
assert_eq!(unsubscribed.status, ThreadUnsubscribeStatus::Unsubscribed);
for include_turns in [false, true] {
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns,
})
.await?;
let read: ThreadReadResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(read.thread.turns.len(), usize::from(include_turns));
assert_eq!(
(read.thread.model.as_deref(), read.thread.reasoning_effort),
(Some(model_id.as_str()), None)
);
}
let list_id = mcp
.send_raw_request("thread/list", Some(json!({ "useStateDbOnly": true })))
.await?;
let listed: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(list_id)).await??;
let listed = listed
.data
.iter()
.find(|listed| listed.id == thread.id)
.expect("loaded thread should be listed");
assert_eq!(
(listed.model.as_deref(), listed.reasoning_effort.clone()),
(Some(model_id.as_str()), None)
);
let unsubscribe_id = mcp
.send_thread_unsubscribe_request(ThreadUnsubscribeParams {
thread_id: thread.id.clone(),
})
.await?;
let unsubscribed: ThreadUnsubscribeResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(unsubscribe_id)).await??;
assert_eq!(unsubscribed.status, ThreadUnsubscribeStatus::NotSubscribed);
let request_bodies = received_response_bodies(&server).await?;
assert!(
@@ -398,19 +472,6 @@ async fn start_thread(mcp: &mut TestAppServer) -> Result<ThreadStartResponse> {
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await?
}
async fn read_thread_with_turns(
mcp: &mut TestAppServer,
thread_id: &str,
) -> Result<ThreadReadResponse> {
let request_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.to_string(),
include_turns: true,
})
.await?;
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await?
}
async fn read_thread_settings_updated(
mcp: &mut TestAppServer,
) -> Result<ThreadSettingsUpdatedNotification> {
@@ -325,7 +325,12 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
create_config_toml(
codex_home.path(),
&server.uri(),
"sandbox_mode = \"read-only\"\nmodel_reasoning_effort = \"high\"",
"",
)?;
// Start server and initialize.
let mut mcp = TestAppServer::builder()
@@ -364,6 +369,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
"new threads should start with an empty preview"
);
assert_eq!(model_provider, "mock_provider");
assert_eq!(
(thread.model.as_deref(), thread.reasoning_effort.clone()),
(Some("gpt-5.2"), Some(ReasoningEffort::High))
);
assert!(
thread.created_at > 0,
"created_at should be a positive UNIX timestamp"