Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<fn(Vec<ResponseItem>) -> Vec<ResponseItem>> =
OnceLock::new();

use crate::agent::AgentStatus;
use crate::agent::agent_status_from_event;
use crate::agent::api::AgentConfigUpdate;
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions codex-rs/local/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions codex-rs/local/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
9 changes: 9 additions & 0 deletions codex-rs/local/resume/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
151 changes: 151 additions & 0 deletions codex-rs/local/resume/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<ResponseItem>) -> Vec<ResponseItem> {
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<ResponseItemId> {
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 { .. }));
}
}
11 changes: 11 additions & 0 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -833,10 +834,14 @@ async fn lookup_latest_session_target_with_app_server(
) -> color_eyre::Result<Option<resume_picker::SessionTarget>> {
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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions codex-rs/tui/src/resume_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -670,6 +670,9 @@ async fn picker_provider_filter(
config: &Config,
app_server: &AppServerSession,
) -> Result<ProviderFilter> {
if crate::resume_all_providers() {
return Ok(ProviderFilter::Any);
}
Ok(app_server
.history_model_provider(config)
.await?
Expand Down
4 changes: 2 additions & 2 deletions local/FORK_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<fn(Vec<ResponseItem>) -> Vec<ResponseItem>>`, 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 |
Expand Down
14 changes: 13 additions & 1 deletion local/fork-surface.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down