Compare commits

...

1 Commits

Author SHA1 Message Date
Dylan Hurd
1d2fddbeba fix(core) preserve model on codex resume 2026-02-03 23:52:47 -08:00
3 changed files with 320 additions and 16 deletions

View File

@@ -44,6 +44,7 @@ use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnContextItem;
use codex_state::ThreadMetadataBuilder;
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
@@ -460,6 +461,24 @@ impl RolloutRecorder {
}))
}
pub async fn read_latest_turn_context(path: &Path) -> std::io::Result<Option<TurnContextItem>> {
let text = tokio::fs::read_to_string(path).await?;
for line in text.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let rollout_line = match serde_json::from_str::<RolloutLine>(trimmed) {
Ok(rollout_line) => rollout_line,
Err(_) => continue,
};
if let RolloutItem::TurnContext(turn_context) = rollout_line.item {
return Ok(Some(turn_context));
}
}
Ok(None)
}
pub async fn shutdown(&self) -> std::io::Result<()> {
let (tx_done, rx_done) = oneshot::channel();
match self.tx.send(RolloutCmd::Shutdown { ack: tx_done }).await {

View File

@@ -17,6 +17,7 @@ use time::macros::format_description;
use uuid::Uuid;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::RolloutRecorder;
use crate::rollout::list::Cursor;
use crate::rollout::list::ThreadItem;
use crate::rollout::list::ThreadSortKey;
@@ -242,6 +243,121 @@ fn write_session_file_with_meta_payload(
Ok(())
}
#[tokio::test]
async fn read_latest_turn_context_returns_last_turn_context_item() -> Result<()> {
let temp_dir = TempDir::new()?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let first_turn_context = codex_protocol::protocol::TurnContextItem {
cwd: temp_dir.path().join("first"),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::ReadOnly,
model: "older-model".to_string(),
personality: None,
collaboration_mode: None,
effort: Some(codex_protocol::openai_models::ReasoningEffort::Low),
summary: codex_protocol::config_types::ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
};
let latest_turn_context = codex_protocol::protocol::TurnContextItem {
cwd: temp_dir.path().join("latest"),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::ReadOnly,
model: "latest-model".to_string(),
personality: None,
collaboration_mode: None,
effort: Some(codex_protocol::openai_models::ReasoningEffort::High),
summary: codex_protocol::config_types::ReasoningSummary::Detailed,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
};
let lines = vec![
RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(first_turn_context),
},
RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "hello".to_string(),
}],
end_turn: None,
phase: None,
}),
},
RolloutLine {
timestamp: "t2".to_string(),
item: RolloutItem::TurnContext(latest_turn_context.clone()),
},
];
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(&line)?);
text.push('\n');
}
std::fs::write(&rollout_path, text)?;
let latest = RolloutRecorder::read_latest_turn_context(&rollout_path)
.await?
.expect("latest turn context");
assert_eq!(
serde_json::to_value(&latest)?,
serde_json::to_value(&latest_turn_context)?
);
Ok(())
}
#[tokio::test]
async fn read_latest_turn_context_skips_invalid_json_lines() -> Result<()> {
let temp_dir = TempDir::new()?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let latest_turn_context = codex_protocol::protocol::TurnContextItem {
cwd: temp_dir.path().join("latest"),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::ReadOnly,
model: "latest-model".to_string(),
personality: None,
collaboration_mode: None,
effort: Some(codex_protocol::openai_models::ReasoningEffort::High),
summary: codex_protocol::config_types::ReasoningSummary::Detailed,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
};
let latest_line = RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::TurnContext(latest_turn_context.clone()),
};
std::fs::write(
&rollout_path,
format!(
"{{not-valid-json}}\n{}\n",
serde_json::to_string(&latest_line)?
),
)?;
let latest = RolloutRecorder::read_latest_turn_context(&rollout_path)
.await?
.expect("latest turn context");
assert_eq!(
serde_json::to_value(&latest)?,
serde_json::to_value(&latest_turn_context)?
);
Ok(())
}
#[tokio::test]
async fn test_list_conversations_latest_first() {
let temp = TempDir::new().unwrap();

View File

@@ -37,8 +37,7 @@ use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::TurnContextItem;
use codex_state::log_db;
use codex_utils_absolute_path::AbsolutePathBuf;
use cwd_prompt::CwdPromptAction;
@@ -655,7 +654,7 @@ async fn run_ratatui_app(
None => None,
};
let config = match &session_selection {
let mut config = match &session_selection {
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
@@ -667,6 +666,8 @@ async fn run_ratatui_app(
}
_ => config,
};
maybe_apply_resume_turn_context_settings(&mut config, &session_selection, cli.model.is_some())
.await;
let active_profile = config.active_profile.clone();
let should_show_trust_screen = should_show_trust_screen(&config);
@@ -708,7 +709,7 @@ pub(crate) async fn read_session_cwd(path: &Path) -> Option<PathBuf> {
// mutating the SessionMeta line when the session cwd changes, but the rollout
// is an append-only JSONL log and rewriting the head would be error-prone.
// When rollouts move to SQLite, we can drop this scan.
if let Some(cwd) = parse_latest_turn_context_cwd(path).await {
if let Some(cwd) = read_latest_turn_context(path).await.map(|ctx| ctx.cwd) {
return Some(cwd);
}
match read_session_meta_line(path).await {
@@ -725,21 +726,41 @@ pub(crate) async fn read_session_cwd(path: &Path) -> Option<PathBuf> {
}
}
async fn parse_latest_turn_context_cwd(path: &Path) -> Option<PathBuf> {
let text = tokio::fs::read_to_string(path).await.ok()?;
for line in text.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
async fn maybe_apply_resume_turn_context_settings(
config: &mut Config,
session_selection: &resume_picker::SessionSelection,
cli_model_override: bool,
) {
if cli_model_override {
return;
}
let rollout_path = match session_selection {
resume_picker::SessionSelection::Resume(path)
| resume_picker::SessionSelection::Fork(path) => path,
resume_picker::SessionSelection::StartFresh | resume_picker::SessionSelection::Exit => {
return;
}
let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) else {
continue;
};
if let RolloutItem::TurnContext(item) = rollout_line.item {
return Some(item.cwd);
};
if let Some(turn_context) = read_latest_turn_context(rollout_path).await {
config.model = Some(turn_context.model);
config.model_reasoning_effort = turn_context.effort;
config.model_reasoning_summary = turn_context.summary;
}
}
async fn read_latest_turn_context(path: &Path) -> Option<TurnContextItem> {
match RolloutRecorder::read_latest_turn_context(path).await {
Ok(turn_context) => turn_context,
Err(err) => {
let rollout_path = path.display().to_string();
tracing::warn!(
%rollout_path,
%err,
"Failed to read latest turn context from rollout"
);
None
}
}
None
}
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
@@ -1019,6 +1040,154 @@ mod tests {
}
}
#[tokio::test]
async fn resume_rehydrates_model_effort_and_summary_from_latest_turn_context()
-> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.model = Some("current-model".to_string());
config.model_reasoning_effort = None;
let first_cwd = temp_dir.path().join("first");
let latest_cwd = temp_dir.path().join("latest");
std::fs::create_dir_all(&first_cwd)?;
std::fs::create_dir_all(&latest_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let mut first_context = build_turn_context(&config, first_cwd);
first_context.model = "older-model".to_string();
first_context.effort = Some(codex_protocol::openai_models::ReasoningEffort::Low);
first_context.summary = codex_protocol::config_types::ReasoningSummary::Auto;
let mut latest_context = build_turn_context(&config, latest_cwd);
latest_context.model = "latest-model".to_string();
latest_context.effort = Some(codex_protocol::openai_models::ReasoningEffort::High);
latest_context.summary = codex_protocol::config_types::ReasoningSummary::Detailed;
let lines = vec![
RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(first_context),
},
RolloutLine {
timestamp: "t1".to_string(),
item: RolloutItem::TurnContext(latest_context),
},
];
let mut text = String::new();
for line in lines {
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
text.push('\n');
}
std::fs::write(&rollout_path, text)?;
maybe_apply_resume_turn_context_settings(
&mut config,
&resume_picker::SessionSelection::Resume(rollout_path),
false,
)
.await;
assert_eq!(config.model.as_deref(), Some("latest-model"));
assert_eq!(
config.model_reasoning_effort,
Some(codex_protocol::openai_models::ReasoningEffort::High)
);
assert_eq!(
config.model_reasoning_summary,
codex_protocol::config_types::ReasoningSummary::Detailed
);
Ok(())
}
#[tokio::test]
async fn resume_does_not_rehydrate_model_when_cli_model_override_present() -> std::io::Result<()>
{
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.model = Some("cli-model".to_string());
config.model_reasoning_effort =
Some(codex_protocol::openai_models::ReasoningEffort::Medium);
let latest_cwd = temp_dir.path().join("latest");
std::fs::create_dir_all(&latest_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let mut latest_context = build_turn_context(&config, latest_cwd);
latest_context.model = "history-model".to_string();
latest_context.effort = Some(codex_protocol::openai_models::ReasoningEffort::Low);
latest_context.summary = codex_protocol::config_types::ReasoningSummary::Concise;
let line = RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(latest_context),
};
std::fs::write(
&rollout_path,
format!(
"{}\n",
serde_json::to_string(&line).expect("serialize rollout")
),
)?;
maybe_apply_resume_turn_context_settings(
&mut config,
&resume_picker::SessionSelection::Resume(rollout_path),
true,
)
.await;
assert_eq!(config.model.as_deref(), Some("cli-model"));
assert_eq!(
config.model_reasoning_effort,
Some(codex_protocol::openai_models::ReasoningEffort::Medium)
);
Ok(())
}
#[tokio::test]
async fn fork_rehydrates_model_effort_and_summary_from_latest_turn_context()
-> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = build_config(&temp_dir).await?;
config.model = Some("current-model".to_string());
config.model_reasoning_effort = Some(codex_protocol::openai_models::ReasoningEffort::Low);
config.model_reasoning_summary = codex_protocol::config_types::ReasoningSummary::Concise;
let latest_cwd = temp_dir.path().join("latest");
std::fs::create_dir_all(&latest_cwd)?;
let rollout_path = temp_dir.path().join("rollout.jsonl");
let mut latest_context = build_turn_context(&config, latest_cwd);
latest_context.model = "forked-history-model".to_string();
latest_context.effort = Some(codex_protocol::openai_models::ReasoningEffort::High);
latest_context.summary = codex_protocol::config_types::ReasoningSummary::Detailed;
let line = RolloutLine {
timestamp: "t0".to_string(),
item: RolloutItem::TurnContext(latest_context),
};
std::fs::write(
&rollout_path,
format!(
"{}\n",
serde_json::to_string(&line).expect("serialize rollout")
),
)?;
maybe_apply_resume_turn_context_settings(
&mut config,
&resume_picker::SessionSelection::Fork(rollout_path),
false,
)
.await;
assert_eq!(config.model.as_deref(), Some("forked-history-model"));
assert_eq!(
config.model_reasoning_effort,
Some(codex_protocol::openai_models::ReasoningEffort::High)
);
assert_eq!(
config.model_reasoning_summary,
codex_protocol::config_types::ReasoningSummary::Detailed
);
Ok(())
}
#[tokio::test]
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;