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
2026-09-01 22:56:18 +00:00
committed by copyberry
parent 6b59cefcbb
commit 986ff1cc7c
50 changed files with 633 additions and 33 deletions
@@ -254,6 +254,8 @@ fn sample_thread_with_metadata(
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
+2
View File
@@ -600,6 +600,8 @@ fn sample_thread(thread_id: &str) -> Thread {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -4126,6 +4126,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -4162,6 +4169,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -20186,6 +20186,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -20222,6 +20229,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -17900,6 +17900,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -17936,6 +17943,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1313,6 +1313,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1349,6 +1356,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1313,6 +1313,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1349,6 +1356,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1313,6 +1313,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1349,6 +1356,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1104,6 +1104,13 @@
"description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.",
"type": "string"
},
"model": {
"description": "Current configured model when loaded, otherwise the latest persisted model. Null when unavailable. This is not per-turn execution telemetry.",
"type": [
"string",
"null"
]
},
"modelProvider": {
"description": "Model provider used for this thread (for example, 'openai').",
"type": "string"
@@ -1140,6 +1147,17 @@
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Current configured reasoning effort when loaded, otherwise the latest persisted effort. Null when unset or unavailable. This is not per-turn execution telemetry."
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { GitInfo } from "./GitInfo";
import type { SessionSource } from "./SessionSource";
import type { ThreadHistoryMode } from "./ThreadHistoryMode";
@@ -44,6 +45,14 @@ historyMode: ThreadHistoryMode, /**
* Model provider used for this thread (for example, 'openai').
*/
modelProvider: string, /**
* Current configured model when loaded, otherwise the latest persisted model.
* Null when unavailable. This is not per-turn execution telemetry.
*/
model: string | null, /**
* Current configured reasoning effort when loaded, otherwise the latest persisted effort.
* Null when unset or unavailable. This is not per-turn execution telemetry.
*/
reasoningEffort: ReasoningEffort | null, /**
* Unix timestamp (in seconds) when the thread was created.
*/
createdAt: number, /**
@@ -3130,6 +3130,8 @@ mod tests {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(3),
@@ -3185,6 +3187,8 @@ mod tests {
"projectId": null,
"historyMode": "legacy",
"modelProvider": "openai",
"model": null,
"reasoningEffort": null,
"createdAt": 1,
"updatedAt": 2,
"recencyAt": 3,
@@ -288,6 +288,8 @@ fn thread_resume_response_round_trips_initial_turns_page() {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 1,
recency_at: Some(1),
@@ -5,6 +5,7 @@ use super::TurnStatus;
use crate::JsonSchema;
use crate::TS;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::MisalignmentErrorDetails as CoreMisalignmentErrorDetails;
use codex_protocol::protocol::MisalignmentSteer as CoreMisalignmentSteer;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
@@ -233,6 +234,12 @@ pub struct Thread {
pub history_mode: ThreadHistoryMode,
/// Model provider used for this thread (for example, 'openai').
pub model_provider: String,
/// Current configured model when loaded, otherwise the latest persisted model.
/// Null when unavailable. This is not per-turn execution telemetry.
pub model: Option<String>,
/// Current configured reasoning effort when loaded, otherwise the latest persisted effort.
/// Null when unset or unavailable. This is not per-turn execution telemetry.
pub reasoning_effort: Option<ReasoningEffort>,
/// Unix timestamp (in seconds) when the thread was created.
#[ts(type = "number")]
pub created_at: i64,
@@ -294,6 +301,8 @@ struct ThreadCompatibility {
#[serde(default)]
history_mode: ThreadHistoryMode,
model_provider: String,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
created_at: i64,
updated_at: i64,
recency_at: Option<i64>,
@@ -330,6 +339,8 @@ impl<'de> Deserialize<'de> for Thread {
project_id: thread.project_id,
history_mode: thread.history_mode,
model_provider: thread.model_provider,
model: thread.model,
reasoning_effort: thread.reasoning_effort,
created_at: thread.created_at,
updated_at: thread.updated_at,
recency_at: thread.recency_at,
+8
View File
@@ -354,6 +354,14 @@ snapshot and any base-user entry; a base-user entry is not required for cleanup.
### Example: Start or resume a thread
The shared `Thread` object includes nullable `model` and `reasoningEffort` fields,
including in `thread/read`, `thread/list`, and `thread/started`. Loaded threads report
their current configured settings; unloaded threads report the latest persisted
values. Unavailable legacy or filesystem-only values remain `null`, and an unset
reasoning effort is also `null`. These fields are not per-turn execution telemetry.
Use `thread/read` or `thread/list` to inspect them without resuming a thread,
subscribing to it, or dispatching queued work or goal continuations.
Start a fresh thread when you need a new Codex conversation.
```json
@@ -3,6 +3,7 @@ use crate::error_code::invalid_request;
use crate::notification_media::without_notification_media;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::request_processors::apply_live_model_settings;
use crate::request_processors::populate_thread_turns_from_history;
use crate::request_processors::thread_from_stored_thread;
use crate::request_processors::thread_settings_from_config_snapshot;
@@ -1245,7 +1246,7 @@ pub(crate) async fn apply_bespoke_event_handling(
return;
}
};
let fallback_cwd = conversation.config_snapshot().await.cwd().clone();
let config_snapshot = conversation.config_snapshot().await;
let stored_thread = match conversation
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
@@ -1268,11 +1269,11 @@ pub(crate) async fn apply_bespoke_event_handling(
let loaded_status = thread_watch_manager
.loaded_status_for_thread(&conversation_id.to_string())
.await;
let response = match thread_rollback_response_from_stored_thread(
let mut response = match thread_rollback_response_from_stored_thread(
stored_thread,
conversation.session_configured().session_id.to_string(),
fallback_model_provider.as_str(),
&fallback_cwd,
config_snapshot.cwd(),
loaded_status,
) {
Ok(response) => response,
@@ -1284,6 +1285,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}
};
apply_live_model_settings(&mut response.thread, &config_snapshot);
outgoing.send_response(request_id, response).await;
}
}
@@ -605,6 +605,14 @@ use crate::thread_state::ThreadStateManager;
use token_usage_replay::restored_token_usage_turn_id;
use token_usage_replay::send_thread_token_usage_update_to_connection;
pub(crate) fn apply_live_model_settings(
thread: &mut Thread,
config_snapshot: &ThreadConfigSnapshot,
) {
thread.model = Some(config_snapshot.model.clone());
thread.reasoning_effort = config_snapshot.reasoning_effort.clone();
}
fn resolve_request_cwd(cwd: Option<PathBuf>) -> Result<Option<AbsolutePathBuf>, JSONRPCErrorError> {
cwd.map(|cwd| {
AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd))
@@ -1,3 +1,4 @@
use super::apply_live_model_settings;
use super::thread_input::can_accept_direct_input;
use crate::thread_status::ThreadWatchManager;
use crate::thread_status::resolve_thread_status;
@@ -32,11 +33,7 @@ pub(super) async fn enrich_loaded_threads<T>(
thread.status = status.clone();
}
if !matches!(
&thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) || matches!(watched_status, Some(ThreadStatus::NotLoaded))
{
if matches!(watched_status, Some(ThreadStatus::NotLoaded)) {
return;
}
@@ -46,6 +43,14 @@ pub(super) async fn enrich_loaded_threads<T>(
let Ok(loaded_thread) = thread_manager.get_thread(thread_id).await else {
return;
};
let config_snapshot = loaded_thread.config_snapshot().await;
apply_live_model_settings(thread, &config_snapshot);
if !matches!(
&thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) {
return;
}
match loaded_thread.agent_status().await {
AgentStatus::Running => {
if watched_status.is_none() {
@@ -68,7 +73,6 @@ pub(super) async fn enrich_loaded_threads<T>(
return;
}
}
let config_snapshot = loaded_thread.config_snapshot().await;
thread.can_accept_direct_input = Some(can_accept_direct_input(
loaded_thread.multi_agent_version(),
&config_snapshot.session_source,
@@ -1991,6 +1991,8 @@ impl ThreadRequestProcessor {
);
if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await {
thread.session_id = loaded_thread.session_configured().session_id.to_string();
let config_snapshot = loaded_thread.config_snapshot().await;
apply_live_model_settings(&mut thread, &config_snapshot);
}
self.attach_thread_name(thread_uuid, &mut thread).await;
thread.status = resolve_thread_status(
@@ -2981,6 +2983,7 @@ impl ThreadRequestProcessor {
} else {
fallback_thread
};
apply_live_model_settings(&mut thread, &config_snapshot);
self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread)
.await?;
Ok(thread)
@@ -4279,6 +4282,7 @@ impl ThreadRequestProcessor {
);
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
thread_summary.thread_source = config_snapshot.thread_source.clone().map(Into::into);
apply_live_model_settings(&mut thread_summary, &config_snapshot);
thread_summary.can_accept_direct_input = Some(can_accept_direct_input(
existing_thread.multi_agent_version(),
&config_snapshot.session_source,
@@ -4643,6 +4647,7 @@ impl ThreadRequestProcessor {
)),
};
let mut thread = thread?;
apply_live_model_settings(&mut thread, &config_snapshot);
thread.can_accept_direct_input = Some(can_accept_direct_input);
thread.id = thread_id.to_string();
thread.session_id = session_id;
@@ -5140,6 +5145,7 @@ impl ThreadRequestProcessor {
));
thread.session_id = session_configured.session_id.to_string();
thread.thread_source = config_snapshot.thread_source.clone().map(Into::into);
apply_live_model_settings(&mut thread, &config_snapshot);
if thread.path.is_none() {
thread.project_id = inherited_project_id.clone();
}
@@ -5926,6 +5932,8 @@ pub(crate) fn thread_from_stored_thread(
} else {
thread.model_provider
},
model: thread.model,
reasoning_effort: thread.reasoning_effort,
created_at: thread.created_at.timestamp(),
updated_at: thread.updated_at.timestamp(),
recency_at: Some(thread.recency_at.timestamp()),
@@ -6097,6 +6105,8 @@ fn build_thread_from_snapshot(
project_id: None,
history_mode: config_snapshot.history_mode.into(),
model_provider: config_snapshot.model_provider_id.clone(),
model: Some(config_snapshot.model.clone()),
reasoning_effort: config_snapshot.reasoning_effort.clone(),
created_at: now,
updated_at: now,
recency_at: Some(now),
@@ -208,6 +208,8 @@ mod tests {
project_id: None,
history_mode: Default::default(),
model_provider: "mock_provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 0,
updated_at: 0,
recency_at: Some(0),
@@ -272,6 +272,8 @@ pub(crate) fn summary_to_thread(
history_mode: ThreadHistoryMode::Legacy,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
model: None,
reasoning_effort: None,
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
recency_at: updated_at.map(|dt| dt.timestamp()),
status: ThreadStatus::NotLoaded,
@@ -1473,6 +1473,8 @@ impl TurnRequestProcessor {
};
if let Some(mut thread) = stored_thread {
let config_snapshot = review_thread.config_snapshot().await;
apply_live_model_settings(&mut thread, &config_snapshot);
thread.session_id = review_thread.session_configured().session_id.to_string();
self.thread_watch_manager
.upsert_thread_silently(&thread.id)
@@ -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"
+4
View File
@@ -363,6 +363,8 @@ fn turn_items_for_thread_returns_matching_turn_items() {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 0,
updated_at: 0,
recency_at: Some(0),
@@ -849,6 +851,8 @@ fn sample_thread_start_response() -> ThreadStartResponse {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 0,
updated_at: 0,
recency_at: Some(0),
+7
View File
@@ -28,6 +28,7 @@ use codex_protocol::RolloutId;
use codex_protocol::SanitizedGitUrl;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
@@ -82,6 +83,10 @@ pub struct ThreadItem {
pub agent_role: Option<String>,
/// Model provider from session metadata.
pub model_provider: Option<String>,
/// Latest persisted model in SQLite-owned metadata, when available.
pub model: Option<String>,
/// Latest persisted reasoning effort in SQLite-owned metadata, when available.
pub reasoning_effort: Option<ReasoningEffort>,
/// CLI version from session metadata.
pub cli_version: Option<String>,
/// RFC3339 timestamp string for when the session was created, if available.
@@ -849,6 +854,8 @@ async fn build_thread_item(
agent_nickname,
agent_role,
model_provider,
model: None,
reasoning_effort: None,
cli_version,
created_at,
recency_at: summary_updated_at.clone(),
+6
View File
@@ -1298,6 +1298,8 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cli_version,
created_at,
updated_at,
@@ -1312,6 +1314,8 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
}
item.section = section;
item.project_id = project_id;
item.model = model;
item.reasoning_effort = reasoning_effort;
if item.cwd.is_none() {
item.cwd = cwd;
}
@@ -2040,6 +2044,8 @@ fn thread_item_from_state_metadata(
agent_nickname: item.agent_nickname,
agent_role: item.agent_role,
model_provider: Some(item.model_provider),
model: item.model,
reasoning_effort: item.reasoning_effort,
cli_version: Some(item.cli_version),
created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)),
+4
View File
@@ -1544,6 +1544,8 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
agent_nickname: None,
agent_role: None,
model_provider: None,
model: None,
reasoning_effort: None,
cli_version: None,
created_at: None,
recency_at: Some("2025-01-03T15:59:00.000Z".to_string()),
@@ -1573,6 +1575,8 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
agent_nickname: Some("state-agent".to_string()),
agent_role: Some("state-role".to_string()),
model_provider: Some("state-provider".to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("state-version".to_string()),
created_at: Some("2025-01-03T16:00:00Z".to_string()),
recency_at: Some("2025-01-03T16:00:30.001Z".to_string()),
+22
View File
@@ -735,6 +735,8 @@ async fn test_list_conversations_latest_first() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-03T12-00-00".into()),
recency_at: updated_times.first().cloned().flatten(),
@@ -757,6 +759,8 @@ async fn test_list_conversations_latest_first() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-02T12-00-00".into()),
recency_at: updated_times.get(1).cloned().flatten(),
@@ -779,6 +783,8 @@ async fn test_list_conversations_latest_first() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-01T12-00-00".into()),
recency_at: updated_times.get(2).cloned().flatten(),
@@ -894,6 +900,8 @@ async fn test_pagination_cursor() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-05T09-00-00".into()),
recency_at: updated_page1.first().cloned().flatten(),
@@ -916,6 +924,8 @@ async fn test_pagination_cursor() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-04T09-00-00".into()),
recency_at: updated_page1.get(1).cloned().flatten(),
@@ -974,6 +984,8 @@ async fn test_pagination_cursor() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-03T09-00-00".into()),
recency_at: updated_page2.first().cloned().flatten(),
@@ -996,6 +1008,8 @@ async fn test_pagination_cursor() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-02T09-00-00".into()),
recency_at: updated_page2.get(1).cloned().flatten(),
@@ -1046,6 +1060,8 @@ async fn test_pagination_cursor() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-01T09-00-00".into()),
recency_at: updated_page3.first().cloned().flatten(),
@@ -1221,6 +1237,8 @@ async fn test_get_thread_contents() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some(ts.into()),
recency_at: page.items[0].updated_at.clone(),
@@ -1624,6 +1642,8 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
recency_at: updated_page1.first().cloned().flatten(),
@@ -1646,6 +1666,8 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
model: None,
reasoning_effort: None,
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
recency_at: updated_page1.get(1).cloned().flatten(),
+2 -2
View File
@@ -164,8 +164,8 @@ pub(super) fn stored_thread_from_rollout_item(
.model_provider
.filter(|provider| !provider.is_empty())
.unwrap_or_else(|| default_provider.to_string()),
model: None,
reasoning_effort: None,
model: item.model,
reasoning_effort: item.reasoning_effort,
created_at,
updated_at,
recency_at,
+20 -1
View File
@@ -32,7 +32,11 @@ pub(super) async fn read_thread(
params: ReadThreadParams,
) -> ThreadStoreResult<StoredThread> {
let thread_id = params.thread_id;
if let Some(metadata) = read_sqlite_metadata(store, thread_id).await
let sqlite_metadata = read_sqlite_metadata(store, thread_id).await;
let persisted_model_settings = sqlite_metadata
.as_ref()
.map(|metadata| (metadata.model.clone(), metadata.reasoning_effort.clone()));
if let Some(metadata) = sqlite_metadata
&& (params.include_archived
|| (metadata.archived_at.is_none()
&& !rollout_path_is_archived(
@@ -66,6 +70,8 @@ pub(super) async fn read_thread(
rollout_thread.name = thread.name;
}
rollout_thread.project_id = thread.project_id;
rollout_thread.model = thread.model;
rollout_thread.reasoning_effort = thread.reasoning_effort;
rollout_thread.git_info = thread.git_info;
rollout_thread.permission_profile = permission_profile_from_metadata_value(
&metadata_sandbox_policy,
@@ -91,6 +97,10 @@ pub(super) async fn read_thread(
})?;
let mut thread = read_thread_from_rollout_path(store, path).await?;
if let Some((model, reasoning_effort)) = persisted_model_settings {
thread.model = model;
thread.reasoning_effort = reasoning_effort;
}
if !params.include_archived && thread.archived_at.is_some() {
return Err(ThreadStoreError::InvalidRequest {
message: format!("thread {} is archived", thread.thread_id),
@@ -141,6 +151,8 @@ pub(super) async fn read_thread_by_rollout_path(
thread.section_position = metadata.section_position;
thread.section_entered_at = metadata.section_entered_at;
thread.project_id = metadata.project_id;
thread.model = metadata.model;
thread.reasoning_effort = metadata.reasoning_effort;
if !metadata.cwd.as_os_str().is_empty()
&& resolve_requested_rollout_path(store, metadata.rollout_path.clone())
.await
@@ -529,6 +541,7 @@ mod tests {
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::SandboxPolicy;
@@ -1266,6 +1279,8 @@ mod tests {
builder.model_provider = Some("stale-sqlite-provider".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.first_user_message = Some("stale sqlite preview".to_string());
metadata.model = Some("persisted-model".to_string());
metadata.reasoning_effort = Some(ReasoningEffort::High);
runtime
.upsert_thread(&metadata)
.await
@@ -1284,6 +1299,10 @@ mod tests {
assert_eq!(thread.rollout_path, Some(rollout_path));
assert_eq!(thread.preview, "Hello from user");
assert_eq!(thread.model_provider, config.default_model_provider_id);
assert_eq!(
(thread.model.as_deref(), thread.reasoning_effort),
(Some("persisted-model"), Some(ReasoningEffort::High))
);
let history = thread.history.expect("history should load");
assert_eq!(history.thread_id, thread_id);
assert_eq!(history.items.len(), 2);
@@ -51,6 +51,8 @@ fn overview_thread(
section_entered_at: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: *OVERVIEW_TIMESTAMP,
updated_at: *OVERVIEW_TIMESTAMP,
recency_at: Some(*OVERVIEW_TIMESTAMP),
+2
View File
@@ -147,6 +147,8 @@ mod tests {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 0,
updated_at: 0,
recency_at: Some(0),
+6
View File
@@ -4040,6 +4040,8 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
project_id: None,
history_mode: Default::default(),
model_provider: "agent-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -4139,6 +4141,8 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
project_id: None,
history_mode: Default::default(),
model_provider: "agent-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -4205,6 +4209,8 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
project_id: None,
history_mode: Default::default(),
model_provider: "read-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
+2
View File
@@ -945,6 +945,8 @@ async fn known_thread_started_preserves_session_without_reading_unmaterialized_r
project_id: None,
history_mode: Default::default(),
model_provider: "notification-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -422,6 +422,8 @@ mod tests {
project_id: None,
history_mode: Default::default(),
model_provider: "read-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
+2
View File
@@ -3640,6 +3640,8 @@ mod tests {
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
+8
View File
@@ -6260,6 +6260,8 @@ session_picker_view = "dense"
project_id: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -6302,6 +6304,8 @@ session_picker_view = "dense"
project_id: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -6383,6 +6387,8 @@ session_picker_view = "dense"
project_id: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
@@ -6456,6 +6462,8 @@ session_picker_view = "dense"
project_id: None,
history_mode: Default::default(),
model_provider: String::from("openai"),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),