Feat: CXA-1831 Persist latest model and reasoning effort in sqlite (#14859)

### Summary
The goal is for us to get the latest turn model and reasoning effort on
thread/resume is no override is provided on the thread/resume func call.
This is the part 1 which we write the model and reasoning effort for a
thread to the sqlite db and there will be a followup PR to consume the
two new fields on thread/resume.

[part 2 PR is currently WIP](https://github.com/openai/codex/pull/14888)
and this one can be merged independently.
This commit is contained in:
Shijie Rao
2026-03-17 10:14:34 -07:00
committed by GitHub
parent 6ea041032b
commit 8e258eb3f5
8 changed files with 237 additions and 3 deletions
@@ -26,6 +26,8 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
agent_nickname: None,
agent_role: None,
model_provider: "test-provider".to_string(),
model: Some("gpt-5".to_string()),
reasoning_effort: None,
cwd: PathBuf::from(cwd),
cli_version: "test".to_string(),
title: title.to_string(),
+24
View File
@@ -4,6 +4,7 @@
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
use std::collections::HashMap;
use std::str::FromStr;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -48,6 +49,15 @@ pub enum ReasoningEffort {
XHigh,
}
impl FromStr for ReasoningEffort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.map_err(|_| format!("invalid reasoning_effort: {s}"))
}
}
/// Canonical user-input modality tags advertised by a model.
#[derive(
Debug,
@@ -552,6 +562,20 @@ mod tests {
}
}
#[test]
fn reasoning_effort_from_str_accepts_known_values() {
assert_eq!("high".parse(), Ok(ReasoningEffort::High));
assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal));
}
#[test]
fn reasoning_effort_from_str_rejects_unknown_values() {
assert_eq!(
"unsupported".parse::<ReasoningEffort>(),
Err("invalid reasoning_effort: unsupported".to_string())
);
}
#[test]
fn get_model_instructions_uses_template_when_placeholder_present() {
let model = test_model(Some(ModelMessages {
@@ -0,0 +1,2 @@
ALTER TABLE threads ADD COLUMN model TEXT;
ALTER TABLE threads ADD COLUMN reasoning_effort TEXT;
+71 -1
View File
@@ -70,6 +70,8 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem)
if metadata.cwd.as_os_str().is_empty() {
metadata.cwd = turn_ctx.cwd.clone();
}
metadata.model = Some(turn_ctx.model.clone());
metadata.reasoning_effort = turn_ctx.effort;
metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy);
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
}
@@ -141,6 +143,7 @@ mod tests {
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
@@ -312,7 +315,7 @@ mod tests {
personality: None,
collaboration_mode: None,
realtime_active: None,
effort: None,
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
@@ -325,6 +328,71 @@ mod tests {
assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace"));
}
#[test]
fn turn_context_sets_model_and_reasoning_effort() {
let mut metadata = metadata_for_test();
apply_rollout_item(
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
trace_id: None,
cwd: PathBuf::from("/fallback/workspace"),
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
network: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: None,
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
}),
"test-provider",
);
assert_eq!(metadata.model.as_deref(), Some("gpt-5"));
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High));
}
#[test]
fn session_meta_does_not_set_model_or_reasoning_effort() {
let mut metadata = metadata_for_test();
let thread_id = metadata.id;
apply_rollout_item(
&mut metadata,
&RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: thread_id,
forked_from_id: None,
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
cwd: PathBuf::from("/workspace"),
originator: "codex_cli_rs".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: Some("openai".to_string()),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: None,
}),
"test-provider",
);
assert_eq!(metadata.model, None);
assert_eq!(metadata.reasoning_effort, None);
}
fn metadata_for_test() -> ThreadMetadata {
let id = ThreadId::from_string(&Uuid::from_u128(42).to_string()).expect("thread id");
let created_at = DateTime::<Utc>::from_timestamp(1_735_689_600, 0).expect("timestamp");
@@ -337,6 +405,8 @@ mod tests {
agent_nickname: None,
agent_role: None,
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
title: String::new(),
+106
View File
@@ -3,6 +3,7 @@ use chrono::DateTime;
use chrono::Timelike;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
@@ -70,6 +71,10 @@ pub struct ThreadMetadata {
pub agent_role: Option<String>,
/// The model provider identifier.
pub model_provider: String,
/// The latest observed model for the thread.
pub model: Option<String>,
/// The latest observed reasoning effort for the thread.
pub reasoning_effort: Option<ReasoningEffort>,
/// The working directory for the thread.
pub cwd: PathBuf,
/// Version of the CLI that created the thread.
@@ -181,6 +186,8 @@ impl ThreadMetadataBuilder {
.model_provider
.clone()
.unwrap_or_else(|| default_provider.to_string()),
model: None,
reasoning_effort: None,
cwd: self.cwd.clone(),
cli_version: self.cli_version.clone().unwrap_or_default(),
title: String::new(),
@@ -237,6 +244,12 @@ impl ThreadMetadata {
if self.model_provider != other.model_provider {
diffs.push("model_provider");
}
if self.model != other.model {
diffs.push("model");
}
if self.reasoning_effort != other.reasoning_effort {
diffs.push("reasoning_effort");
}
if self.cwd != other.cwd {
diffs.push("cwd");
}
@@ -288,6 +301,8 @@ pub(crate) struct ThreadRow {
agent_nickname: Option<String>,
agent_role: Option<String>,
model_provider: String,
model: Option<String>,
reasoning_effort: Option<String>,
cwd: String,
cli_version: String,
title: String,
@@ -312,6 +327,8 @@ impl ThreadRow {
agent_nickname: row.try_get("agent_nickname")?,
agent_role: row.try_get("agent_role")?,
model_provider: row.try_get("model_provider")?,
model: row.try_get("model")?,
reasoning_effort: row.try_get("reasoning_effort")?,
cwd: row.try_get("cwd")?,
cli_version: row.try_get("cli_version")?,
title: row.try_get("title")?,
@@ -340,6 +357,8 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
@@ -361,6 +380,9 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort: reasoning_effort
.and_then(|value| value.parse::<ReasoningEffort>().ok()),
cwd: PathBuf::from(cwd),
cli_version,
title,
@@ -404,3 +426,87 @@ pub struct BackfillStats {
/// The number of rows that failed to upsert.
pub failed: usize,
}
#[cfg(test)]
mod tests {
use super::ThreadMetadata;
use super::ThreadRow;
use chrono::DateTime;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow {
ThreadRow {
id: "00000000-0000-0000-0000-000000000123".to_string(),
rollout_path: "/tmp/rollout-123.jsonl".to_string(),
created_at: 1_700_000_000,
updated_at: 1_700_000_100,
source: "cli".to_string(),
agent_nickname: None,
agent_role: None,
model_provider: "openai".to_string(),
model: Some("gpt-5".to_string()),
reasoning_effort: reasoning_effort.map(str::to_string),
cwd: "/tmp/workspace".to_string(),
cli_version: "0.0.0".to_string(),
title: String::new(),
sandbox_policy: "read-only".to_string(),
approval_mode: "on-request".to_string(),
tokens_used: 1,
first_user_message: String::new(),
archived_at: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
}
}
fn expected_thread_metadata(reasoning_effort: Option<ReasoningEffort>) -> ThreadMetadata {
ThreadMetadata {
id: ThreadId::from_string("00000000-0000-0000-0000-000000000123")
.expect("valid thread id"),
rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"),
created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"),
updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
source: "cli".to_string(),
agent_nickname: None,
agent_role: None,
model_provider: "openai".to_string(),
model: Some("gpt-5".to_string()),
reasoning_effort,
cwd: PathBuf::from("/tmp/workspace"),
cli_version: "0.0.0".to_string(),
title: String::new(),
sandbox_policy: "read-only".to_string(),
approval_mode: "on-request".to_string(),
tokens_used: 1,
first_user_message: None,
archived_at: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
}
}
#[test]
fn thread_row_parses_reasoning_effort() {
let metadata = ThreadMetadata::try_from(thread_row(Some("high")))
.expect("thread metadata should parse");
assert_eq!(
metadata,
expected_thread_metadata(Some(ReasoningEffort::High))
);
}
#[test]
fn thread_row_ignores_unknown_reasoning_effort_values() {
let metadata = ThreadMetadata::try_from(thread_row(Some("future")))
.expect("thread metadata should parse");
assert_eq!(metadata, expected_thread_metadata(None));
}
}
+2
View File
@@ -169,6 +169,8 @@ SELECT
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
@@ -5,6 +5,8 @@ use chrono::Utc;
#[cfg(test)]
use codex_protocol::ThreadId;
#[cfg(test)]
use codex_protocol::openai_models::ReasoningEffort;
#[cfg(test)]
use codex_protocol::protocol::AskForApproval;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
@@ -49,6 +51,8 @@ pub(super) fn test_thread_metadata(
agent_nickname: None,
agent_role: None,
model_provider: "test-provider".to_string(),
model: Some("gpt-5".to_string()),
reasoning_effort: Some(ReasoningEffort::Medium),
cwd,
cli_version: "0.0.0".to_string(),
title: String::new(),
+26 -2
View File
@@ -13,6 +13,8 @@ SELECT
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
@@ -125,6 +127,8 @@ SELECT
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
@@ -223,6 +227,8 @@ INSERT INTO threads (
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
@@ -236,7 +242,7 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
@@ -248,6 +254,13 @@ ON CONFLICT(id) DO NOTHING
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.model_provider.as_str())
.bind(metadata.model.as_deref())
.bind(
metadata
.reasoning_effort
.as_ref()
.map(crate::extract::enum_to_string),
)
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())
@@ -337,6 +350,8 @@ INSERT INTO threads (
agent_nickname,
agent_role,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
@@ -350,7 +365,7 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
@@ -359,6 +374,8 @@ ON CONFLICT(id) DO UPDATE SET
agent_nickname = excluded.agent_nickname,
agent_role = excluded.agent_role,
model_provider = excluded.model_provider,
model = excluded.model,
reasoning_effort = excluded.reasoning_effort,
cwd = excluded.cwd,
cli_version = excluded.cli_version,
title = excluded.title,
@@ -381,6 +398,13 @@ ON CONFLICT(id) DO UPDATE SET
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.model_provider.as_str())
.bind(metadata.model.as_deref())
.bind(
metadata
.reasoning_effort
.as_ref()
.map(crate::extract::enum_to_string),
)
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())