diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0f5661c54d..5288a722f0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 6e4ee4d1ec..c5a7b193a5 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -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), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index ed50dfc392..c7e7930699 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bc3a3bc591..ec97c1f850 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 189ca84107..8e83826dbc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index f8e52d28f1..8d2527a200 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f1d70c77a1..52303c2077 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6de156b488..d9c006b309 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index ba130748ff..0ee3e3e714 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 7016e7f077..68e73e7673 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 65dfd421b7..ffb3659ea5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 210c784af9..2afaf9ec79 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 6d82e1c5b6..26cc859c5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 33dc4dec94..7f2ece07d0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index ed4edd1194..66371aed4c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 5222e1fd8c..3aa3191608 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index fe8c718526..900a626ab2 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5b6f44c1a7..61e91df781 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -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, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1e25265c2b..6c00f889f5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c07b801c0d..141f867c1b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 131d42347c..649a94be49 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -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, + /// 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, /// 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, + reasoning_effort: Option, created_at: i64, updated_at: i64, recency_at: Option, @@ -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, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 552c91e2b5..cbfde21753 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 68a1862a50..68100e6c92 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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; } } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 103fabf2fe..60875fadaf 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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) -> Result, JSONRPCErrorError> { cwd.map(|cwd| { AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) diff --git a/codex-rs/app-server/src/request_processors/thread_enrichment.rs b/codex-rs/app-server/src/request_processors/thread_enrichment.rs index 30e3fb377e..e90db06499 100644 --- a/codex-rs/app-server/src/request_processors/thread_enrichment.rs +++ b/codex-rs/app-server/src/request_processors/thread_enrichment.rs @@ -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( 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( 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( 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, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 476aec798c..56eb6fdc93 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -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), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 1d0c9e7552..51e00965ce 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -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), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 5dacd34643..df5beea0cb 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -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, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index eb7cb98551..833475ff29 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -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) diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 202bbbe82c..76b2741d1c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -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 diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index f4fce236f1..ca44c3380d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -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 { diff --git a/codex-rs/app-server/tests/suite/v2/thread_queue.rs b/codex-rs/app-server/tests/suite/v2/thread_queue.rs index 1b7099dc45..caaf4f77ee 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_queue.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_queue.rs @@ -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(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index cd00995a4d..3300a1da9d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -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(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 4a782c2330..8bad7219dd 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -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, .. } => { diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index bbab6e3d6c..dee123f590 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -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 { timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await? } -async fn read_thread_with_turns( - mcp: &mut TestAppServer, - thread_id: &str, -) -> Result { - 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 { diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 59d3261291..6ce038c859 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -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" diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 317baf3c0a..c6e40dc733 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -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), diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index f01fee8200..d54aa44a82 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -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, /// Model provider from session metadata. pub model_provider: Option, + /// Latest persisted model in SQLite-owned metadata, when available. + pub model: Option, + /// Latest persisted reasoning effort in SQLite-owned metadata, when available. + pub reasoning_effort: Option, /// CLI version from session metadata. pub cli_version: Option, /// 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(), diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index dff3f52d3a..aa08d4a733 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -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)), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 23afea6153..b5d3ac9855 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -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()), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 30ba0bd8e8..915aa84f36 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -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(), diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index a4f638945d..000c01d1aa 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -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, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index a28269d7f5..02428a1df2 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -32,7 +32,11 @@ pub(super) async fn read_thread( params: ReadThreadParams, ) -> ThreadStoreResult { 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); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 1598b60a97..4cb7d3b8f1 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -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), diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 8bdd92d3ad..e47dc1de35 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -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), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index df1a1f20b7..b6300ee399 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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), diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 2912ba1e63..b596683df8 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -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), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index dfcc340800..531ded8ab1 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -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), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f316082656..92adb3eec3 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 353bfa65ed..a549819156 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -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),