From f416924300954c6aad78ab7d5081481660ba5506 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Wed, 14 Oct 2026 14:32:57 -0700 Subject: [PATCH 1/2] Phase 5: resume sanitizer (H3) and all-providers filesystem-first picker (H4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qp4Gg5UQcjWVPZz3y58bWs --- codex-rs/Cargo.lock | 7 ++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 18 ++++ codex-rs/local/resume/Cargo.toml | 9 ++ codex-rs/local/resume/src/lib.rs | 151 ++++++++++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 11 +++ codex-rs/tui/src/resume_picker.rs | 7 +- local/FORK_SURFACE.md | 4 +- 8 files changed, 204 insertions(+), 4 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c1f3e4fdcc4f..9e7ed8d4fadb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3943,6 +3943,13 @@ dependencies = [ [[package]] name = "codex-local-resume" version = "0.162.1" +dependencies = [ + "codex-core", + "codex-protocol", + "codex-tui", + "serde_json", + "tracing", +] [[package]] name = "codex-login" diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a2b44e3f1fb6..f8af7a6a9d41 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ pub use responses_headers::CodexResponsesHeaders; mod responses_metadata; mod responses_retry; pub(crate) mod session; +pub use session::RESUME_HISTORY_TRANSFORM; pub use codex_protocol::turn_input::NotSubmittedReason; pub use codex_protocol::turn_input::RecoverTurnRequest; pub use codex_protocol::turn_input::StartIfIdleSubmission; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index bc086f8661f3..13ba7856fd6d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -7,10 +7,15 @@ use std::collections::HashSet; use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; +use std::sync::OnceLock; use std::sync::atomic::AtomicU64; use std::time::SystemTime; use std::time::UNIX_EPOCH; +/// codex-local hook (H3): resume/fork history transform. +pub static RESUME_HISTORY_TRANSFORM: OnceLock) -> Vec> = + OnceLock::new(); + use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; use crate::agent::api::AgentConfigUpdate; @@ -1776,6 +1781,19 @@ impl Session { } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; + if let Some(transform) = RESUME_HISTORY_TRANSFORM.get() { + history = history + .into_iter() + .flat_map(|e| { + transform(vec![e.item]) + .into_iter() + .map(move |item| ResponseItemEnvelope { + item, + metadata: e.metadata.clone(), + }) + }) + .collect(); + } // Keep the recorded rollout unchanged. Prepare its reconstructed history before // installing it, so legacy media is processed once for this resume or fork and // will be processed again if the rollout is reconstructed in a future session. diff --git a/codex-rs/local/resume/Cargo.toml b/codex-rs/local/resume/Cargo.toml index e46749051ebd..e7118d4c2c48 100644 --- a/codex-rs/local/resume/Cargo.toml +++ b/codex-rs/local/resume/Cargo.toml @@ -9,3 +9,12 @@ workspace = true [lib] doctest = false + +[dependencies] +codex-core = { workspace = true } +codex-protocol = { workspace = true } +codex-tui = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/codex-rs/local/resume/src/lib.rs b/codex-rs/local/resume/src/lib.rs index 8b137891791f..c4d3645d6348 100644 --- a/codex-rs/local/resume/src/lib.rs +++ b/codex-rs/local/resume/src/lib.rs @@ -1 +1,152 @@ +//! Cross-resume support for codex-local: a sanitizer for histories written by other +//! providers, plus the resume picker registration. +use codex_protocol::models::ResponseItem; + +/// Prepares an in-memory history for resuming under a different provider. +/// +/// Drops items whose payload is opaque to other providers: reasoning and compaction items +/// carrying `encrypted_content`. Clears provider-assigned item ids. Function calls are never +/// dropped (so each call stays paired with its output, even under per-item application): +/// the encrypted args are cleared, and empty arguments become `"{}"`. +/// Infallible: it never panics and never returns an error. +pub fn sanitize_history(items: Vec) -> Vec { + items + .into_iter() + .filter_map(|mut item| { + match &mut item { + ResponseItem::Reasoning { + encrypted_content: Some(_), + .. + } + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { + encrypted_content: Some(_), + .. + } => { + tracing::debug!("resume sanitizer: dropping item with encrypted payload"); + return None; + } + ResponseItem::FunctionCall { + arguments, + encrypted_function_args, + .. + } if encrypted_function_args.is_some() => { + *encrypted_function_args = None; + if arguments.is_empty() { + *arguments = "{}".to_string(); + } + } + _ => {} + } + item.set_id(None); + Some(item) + }) + .collect() +} + +/// Registers the sanitizer (H3) and the all-providers, filesystem-first picker (H4). +/// Idempotent; the first registration wins. +pub fn register() { + let _ = codex_core::RESUME_HISTORY_TRANSFORM.set(sanitize_history); + let _ = codex_tui::RESUME_ALL_PROVIDERS.set(()); +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ResponseItemId; + use codex_protocol::models::ContentItem; + use codex_protocol::models::FunctionCallOutputPayload; + + fn id(s: &str) -> Option { + serde_json::from_value(serde_json::json!(s)).ok() + } + + #[test] + fn strips_encrypted_reasoning_clears_ids_keeps_messages_and_tools() { + let items = vec![ + ResponseItem::Reasoning { + id: id("rs_1"), + summary: vec![], + content: None, + encrypted_content: Some("blob".into()), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Message { + id: id("msg_1"), + role: "assistant".into(), + content: vec![ContentItem::OutputText { text: "hi".into() }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCall { + id: id("fc_1"), + name: "shell".into(), + namespace: None, + arguments: "{}".into(), + encrypted_function_args: None, + call_id: "call_1".into(), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCallOutput { + id: id("fco_1"), + call_id: Some("call_1".into()), + name: None, + namespace: None, + output: FunctionCallOutputPayload::from_text("ok".into()), + internal_chat_message_metadata_passthrough: None, + }, + ]; + let out = sanitize_history(items); + assert_eq!(out.len(), 3); + assert!(matches!(&out[1], ResponseItem::FunctionCall { encrypted_function_args: None, .. })); + assert!(out.iter().all(|i| i.id().is_none())); + assert!(matches!(&out[0], ResponseItem::Message { content, .. } + if matches!(&content[0], ContentItem::OutputText { text } if text == "hi"))); + assert!( + matches!(&out[1], ResponseItem::FunctionCall { call_id, .. } if call_id == "call_1") + ); + assert!( + matches!(&out[2], ResponseItem::FunctionCallOutput { call_id, .. } if call_id.as_deref() == Some("call_1")) + ); + } + + #[test] + fn keeps_encrypted_only_calls_with_empty_args_and_drops_compaction() { + let items = vec![ + ResponseItem::FunctionCall { + id: None, + name: "shell".into(), + namespace: None, + arguments: String::new(), + encrypted_function_args: Some(vec!["blob".to_string()]), + call_id: "call_2".into(), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCallOutput { + id: None, + call_id: Some("call_2".into()), + name: None, + namespace: None, + output: FunctionCallOutputPayload::from_text("ok".into()), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Compaction { + id: None, + encrypted_content: "blob".into(), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::ContextCompaction { + id: None, + encrypted_content: Some("blob".into()), + internal_chat_message_metadata_passthrough: None, + }, + ]; + let out = sanitize_history(items); + assert_eq!(out.len(), 2); + assert!(matches!(&out[0], ResponseItem::FunctionCall { arguments, encrypted_function_args: None, .. } + if arguments == "{}")); + assert!(matches!(&out[1], ResponseItem::FunctionCallOutput { .. })); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index dc0fbd0547e3..7f7094b0215a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -812,6 +812,7 @@ async fn lookup_session_target_with_app_server( } let model_provider = app_server.history_model_provider(config).await?; + let model_provider = model_provider.filter(|_| !crate::resume_all_providers()); Ok(named_session_lookup::lookup( app_server, config.codex_home.as_path(), @@ -833,10 +834,14 @@ async fn lookup_latest_session_target_with_app_server( ) -> color_eyre::Result> { let uses_remote_workspace = app_server.uses_remote_workspace(); let model_provider = app_server.history_model_provider(config).await?; + let model_provider = model_provider.filter(|_| !resume_all_providers()); for lookup_mode in [ LatestSessionLookupMode::StateDbOnly, LatestSessionLookupMode::ScanAndRepair, ] { + if resume_all_providers() && lookup_mode == LatestSessionLookupMode::StateDbOnly { + continue; + } let response = app_server .thread_list(latest_session_lookup_params( uses_remote_filesystem, @@ -864,6 +869,12 @@ async fn lookup_latest_session_target_with_app_server( Ok(None) } +/// codex-local hook (H4): all-providers, filesystem-first resume lookups. +pub static RESUME_ALL_PROVIDERS: std::sync::OnceLock<()> = std::sync::OnceLock::new(); +pub(crate) fn resume_all_providers() -> bool { + RESUME_ALL_PROVIDERS.get().is_some() +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum LatestSessionLookupMode { StateDbOnly, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 40b6d7440262..9d449afc5531 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -467,7 +467,7 @@ async fn run_resume_picker_with_launch_context( codex_home: local_settings.codex_home.to_path_buf(), }), keymap: runtime_keymap, - initial_page_mode: if uses_remote_workspace { + initial_page_mode: if uses_remote_workspace || crate::resume_all_providers() { PageLoadMode::StoreDefault } else { PageLoadMode::StateDbOnly @@ -528,7 +528,7 @@ pub async fn run_fork_picker_with_app_server( codex_home: local_settings.codex_home.to_path_buf(), }), keymap: runtime_keymap, - initial_page_mode: if uses_remote_workspace { + initial_page_mode: if uses_remote_workspace || crate::resume_all_providers() { PageLoadMode::StoreDefault } else { PageLoadMode::StateDbOnly @@ -670,6 +670,9 @@ async fn picker_provider_filter( config: &Config, app_server: &AppServerSession, ) -> Result { + if crate::resume_all_providers() { + return Ok(ProviderFilter::Any); + } Ok(app_server .history_model_provider(config) .await? diff --git a/local/FORK_SURFACE.md b/local/FORK_SURFACE.md index ef5e8b26f1cd..98efcf7ffff1 100644 --- a/local/FORK_SURFACE.md +++ b/local/FORK_SURFACE.md @@ -4,8 +4,8 @@ |------|---------------|---------|--------| | H1 | `codex-rs/cli/src/main.rs` (+1 line in `codex-rs/cli/build.rs`) | `cli_main` takes argv (`parse_from`) so `codex-local-cli`, which `include!`s this file as its crate root, can call it with its own argv. Stock `main` is gated `#[cfg(not(codex_local))]`. 11 lines added, 2 removed in main.rs (the `Box::pin(cli_main(..))` call grew from 1 to 6 lines via rustfmt); build.rs declares the cfg. | Done | | H2 | `codex-rs/model-provider/src/provider.rs` (+ 2 `pub use` lines in `lib.rs`) | `OnceLock` `LOCAL_MODELS_FACTORY` + `register_local_models_factory`; `create_models_manager` consults it only when provider `name == "codex-local"` (no provider id exists at this layer). Unregistered: stock behaviour. Lines: 17 hook + 19 unit test. Re-apply: add the static, `local_models_manager()` helper and the 3-line early return after the `config_model_catalog` check in `create_models_manager`; re-export from lib.rs. | done | -| H3 | TODO | TODO | TODO | -| H4 | TODO | TODO | TODO | +| H3 | `codex-rs/core/src/session/mod.rs`, `codex-rs/core/src/lib.rs` | `pub static RESUME_HISTORY_TRANSFORM: OnceLock) -> Vec>`, applied per item (metadata sidecar stays aligned) in `apply_rollout_reconstruction` before media preparation; rollout file never rewritten. Re-apply: add the static after the `std` imports, the `if let Some(transform) = ...get()` block before the "Keep the recorded rollout unchanged" comment, and `pub use session::RESUME_HISTORY_TRANSFORM;` in lib.rs. Registered by `codex_local_resume::register()`. | 19 lines, 2 files | +| H4 | `codex-rs/tui/src/lib.rs`, `codex-rs/tui/src/resume_picker.rs` | `pub static RESUME_ALL_PROVIDERS: OnceLock<()>` (tui-owned, set by `codex_local_resume::register()`). When set: picker uses `ProviderFilter::Any` and `PageLoadMode::StoreDefault` (filesystem-first, read-repairs the local DB; fixes AUD3-1), `resume --last` skips the `StateDbOnly` pass and drops the provider filter, named lookup drops the provider filter (1 line). Re-apply: static + `resume_all_providers()` in lib.rs plus the inline checks at those sites; in resume_picker.rs the early return in `picker_provider_filter` and `|| crate::resume_all_providers()` on both `initial_page_mode` conditions. | 16 added / 2 changed lines, 2 files, no tests upstream | | H5 | TODO | TODO | TODO | | H6 | TODO | TODO | TODO | | H7 | TODO | TODO | TODO | From 00b0e5dc3244fce6a580b884b7c3867b0447571c Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Wed, 14 Oct 2026 15:11:20 -0700 Subject: [PATCH 2/2] codex-local: register resume hooks in cli; add resume files to fork-surface allowlist Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Qp4Gg5UQcjWVPZz3y58bWs --- codex-rs/Cargo.lock | 1 + codex-rs/local/cli/Cargo.toml | 1 + codex-rs/local/cli/src/main.rs | 1 + local/fork-surface.toml | 14 +++++++++++++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9e7ed8d4fadb..592145cceebd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3871,6 +3871,7 @@ dependencies = [ "codex-http-client", "codex-install-context", "codex-local-models", + "codex-local-resume", "codex-login", "codex-mcp", "codex-memories-write", diff --git a/codex-rs/local/cli/Cargo.toml b/codex-rs/local/cli/Cargo.toml index d5e70310dd5f..d8cc7adae5d0 100644 --- a/codex-rs/local/cli/Cargo.toml +++ b/codex-rs/local/cli/Cargo.toml @@ -24,6 +24,7 @@ test = false [dependencies] codex-cli = { path = "../../cli" } codex-local-models = { path = "../models" } +codex-local-resume = { path = "../resume" } dirs = { workspace = true } toml_edit = { workspace = true } codex-utils-process = { workspace = true } diff --git a/codex-rs/local/cli/src/main.rs b/codex-rs/local/cli/src/main.rs index 9c45c2d09632..c2d8e29e73fd 100644 --- a/codex-rs/local/cli/src/main.rs +++ b/codex-rs/local/cli/src/main.rs @@ -21,6 +21,7 @@ fn main() -> anyhow::Result<()> { // SAFETY: still single-threaded; no runtime or worker threads exist yet. unsafe { std::env::set_var("CODEX_HOME", &codex_home) }; codex_local_models::register(); + codex_local_resume::register(); let remote_control_disabled = codex_app_server::take_remote_control_disabled_env(); arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move { Box::pin(cli_main(argv, arg0_paths, remote_control_disabled)).await?; diff --git a/local/fork-surface.toml b/local/fork-surface.toml index 0657eff503c8..c3d49f48e8a5 100644 --- a/local/fork-surface.toml +++ b/local/fork-surface.toml @@ -1,5 +1,17 @@ # Upstream paths the fork is allowed to modify. -allowlist = ["codex-rs/Cargo.toml", "codex-rs/cli/src/main.rs", "codex-rs/cli/build.rs", "codex-rs/core/src/tools/router.rs", "codex-rs/core/src/tools/router_tests.rs", "codex-rs/model-provider/src/provider.rs", "codex-rs/model-provider/src/lib.rs"] +allowlist = [ + "codex-rs/Cargo.toml", + "codex-rs/cli/src/main.rs", + "codex-rs/cli/build.rs", + "codex-rs/core/src/tools/router.rs", + "codex-rs/core/src/tools/router_tests.rs", + "codex-rs/model-provider/src/provider.rs", + "codex-rs/model-provider/src/lib.rs", + "codex-rs/core/src/lib.rs", + "codex-rs/core/src/session/mod.rs", + "codex-rs/tui/src/lib.rs", + "codex-rs/tui/src/resume_picker.rs", +] generated = ["codex-rs/Cargo.lock"] [budget]