From 905987c08f40bc8fb8f36d69e8137865e1a67393 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 4 May 2026 10:55:49 -0700 Subject: [PATCH 1/7] Prepare selected environment plumbing (#20669) ## Why This is a prep PR in the multi-environment process-tool stack. It separates ownership/config cleanup from the behavior change that teaches process tools to route by selected environment, so the follow-up PR can focus on model-facing `environment_id` behavior. ## Stack 1. https://github.com/openai/codex/pull/20646 - `EnvironmentContext` rendering for selected environments 2. https://github.com/openai/codex/pull/20669 - selected-environment ownership and tool config prep (this PR) 3. https://github.com/openai/codex/pull/20647 - process-tool `environment_id` routing ## What Changed - keep the resolved turn environment list wrapped in `ResolvedTurnEnvironments` through `TurnContext` instead of unwrapping it back to a raw `Vec` - add `TurnContext::resolve_path_against` so cwd-relative path resolution has one shared helper - replace the old tool config boolean with `ToolEnvironmentMode::{None, Single, Multiple}` ## Testing - Tests not run locally; this prep refactor is covered by GitHub CI for the stack. Co-authored-by: Codex --- codex-rs/core/src/codex_delegate.rs | 5 +- .../core/src/context/environment_context.rs | 4 +- codex-rs/core/src/environment_selection.rs | 10 +-- codex-rs/core/src/session/mcp.rs | 2 +- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/review.rs | 2 +- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/session/tests.rs | 72 +++++++++++-------- codex-rs/core/src/session/turn_context.rs | 24 +++---- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/core/src/thread_manager_tests.rs | 24 +++++-- .../core/src/tools/handlers/agent_jobs.rs | 8 +-- .../core/src/tools/handlers/apply_patch.rs | 5 +- .../src/tools/handlers/multi_agents/spawn.rs | 40 +++++------ .../tools/handlers/multi_agents_v2/spawn.rs | 8 +-- codex-rs/core/src/tools/handlers/shell.rs | 2 +- .../core/src/tools/handlers/unified_exec.rs | 2 +- .../core/src/tools/handlers/view_image.rs | 2 +- .../core/src/tools/runtimes/apply_patch.rs | 2 +- .../core/src/tools/runtimes/unified_exec.rs | 7 +- codex-rs/core/src/unified_exec/mod_tests.rs | 9 ++- codex-rs/tools/src/lib.rs | 1 + codex-rs/tools/src/tool_config.rs | 29 ++++++-- codex-rs/tools/src/tool_registry_plan.rs | 12 ++-- .../tools/src/tool_registry_plan_tests.rs | 3 +- 25 files changed, 155 insertions(+), 123 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d142d33a2f..1aeb125091 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -30,7 +30,6 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use crate::config::Config; -use crate::environment_selection::ResolvedTurnEnvironments; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; use crate::guardian::routes_approval_to_guardian; @@ -94,9 +93,7 @@ pub(crate) async fn run_codex_thread_interactive( inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), parent_trace: None, - environment_selections: ResolvedTurnEnvironments { - turn_environments: parent_ctx.environments.clone(), - }, + environment_selections: parent_ctx.environments.clone(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), })) diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 1f7313de28..c647550ee2 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -176,7 +176,9 @@ impl EnvironmentContext { pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self { Self::new( - EnvironmentContextEnvironment::from_turn_environments(&turn_context.environments), + EnvironmentContextEnvironment::from_turn_environments( + &turn_context.environments.turn_environments, + ), turn_context.current_date.clone(), turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index bbf059069d..e9d617cbf4 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -24,7 +24,7 @@ pub(crate) fn default_thread_environment_selections( .collect() } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub(crate) struct ResolvedTurnEnvironments { pub(crate) turn_environments: Vec, } @@ -37,17 +37,17 @@ impl ResolvedTurnEnvironments { .collect() } - pub(crate) fn primary_turn_environment(&self) -> Option<&TurnEnvironment> { + pub(crate) fn primary(&self) -> Option<&TurnEnvironment> { self.turn_environments.first() } pub(crate) fn primary_environment(&self) -> Option> { - self.primary_turn_environment() + self.primary() .map(|environment| Arc::clone(&environment.environment)) } pub(crate) fn primary_filesystem(&self) -> Option> { - self.primary_turn_environment() + self.primary() .map(|environment| environment.environment.get_filesystem()) } } @@ -171,7 +171,7 @@ mod tests { assert_eq!( resolved - .primary_turn_environment() + .primary() .expect("primary environment") .environment_id, "local" diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 2aa5adee28..5cdc1daa65 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -221,7 +221,7 @@ impl Session { let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await; - let mcp_runtime_environment = match turn_context.primary_environment() { + let mcp_runtime_environment = match turn_context.environments.primary() { Some(turn_environment) => McpRuntimeEnvironment::new( Arc::clone(&turn_environment.environment), turn_environment.cwd.to_path_buf(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index db297d6426..cb8c21c4da 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -355,6 +355,7 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_tools::ToolEnvironmentMode; use codex_tools::ToolsConfig; use codex_tools::ToolsConfigParams; use codex_utils_absolute_path::AbsolutePathBuf; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index b879c78fae..67c1e33310 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -52,7 +52,7 @@ pub(super) async fn spawn_review_thread( ) .with_web_search_config(/*web_search_config*/ None) .with_allow_login_shell(config.permissions.allow_login_shell) - .with_has_environment(parent_turn_context.tools_config.has_environment) + .with_environment_mode(parent_turn_context.tools_config.environment_mode) .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 50b3345d61..ac6f766756 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -968,7 +968,7 @@ impl Session { "unknown stored MCP environment id", )) })? - .primary_turn_environment() + .primary() .cloned(); let mcp_runtime_environment = match turn_environment { Some(turn_environment) => McpRuntimeEnvironment::new( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 89633daf35..b8554b25e9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2944,13 +2944,15 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati fn turn_environments_for_tests( environment: &Arc, cwd: &codex_utils_absolute_path::AbsolutePathBuf, -) -> Vec { - vec![TurnEnvironment { - environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), - environment: Arc::clone(environment), - cwd: cwd.clone(), - shell: "bash".to_string(), - }] +) -> crate::environment_selection::ResolvedTurnEnvironments { + crate::environment_selection::ResolvedTurnEnvironments { + turn_environments: vec![TurnEnvironment { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + environment: Arc::clone(environment), + cwd: cwd.clone(), + shell: "bash".to_string(), + }], + } } #[tokio::test] @@ -3399,7 +3401,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { assert_eq!(turn_context.cwd, absolute_cwd); assert_eq!(turn_context.config.cwd, absolute_cwd); - assert_eq!(turn_context.environments.len(), 1); + assert_eq!(turn_context.environments.turn_environments.len(), 1); } #[tokio::test] @@ -4418,15 +4420,16 @@ async fn turn_environments_set_primary_environment() { .expect("turn should start"); let turn_environments = &turn_context.environments; - assert_eq!(turn_environments.len(), 1); + assert_eq!(turn_environments.turn_environments.len(), 1); let turn_environment = turn_context - .primary_environment() + .environments + .primary() .expect("primary environment should be set"); assert!(std::sync::Arc::ptr_eq( &turn_environment.environment, - &turn_environments[0].environment + &turn_environments.turn_environments[0].environment )); - assert!(!turn_context.environments.is_empty()); + assert!(!turn_context.environments.turn_environments.is_empty()); assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); } @@ -4449,13 +4452,14 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { let turn_context = session.new_default_turn().await; let turn_environments = &turn_context.environments; - assert_eq!(turn_environments.len(), 1); + assert_eq!(turn_environments.turn_environments.len(), 1); let turn_environment = turn_context - .primary_environment() + .environments + .primary() .expect("primary environment should be set"); assert!(std::sync::Arc::ptr_eq( &turn_environment.environment, - &turn_environments[0].environment + &turn_environments.turn_environments[0].environment )); assert_eq!(turn_context.cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); @@ -4473,28 +4477,32 @@ async fn default_turn_honors_empty_stored_thread_environments() { let turn_context = session.new_default_turn().await; - assert!(turn_context.primary_environment().is_none()); - assert!(turn_context.environments.is_empty()); + assert!(turn_context.environments.primary().is_none()); + assert!(turn_context.environments.turn_environments.is_empty()); assert_eq!(turn_context.cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); - assert_eq!(turn_context.environments.len(), 0); + assert_eq!(turn_context.environments.turn_environments.len(), 0); } #[tokio::test] async fn primary_environment_uses_first_turn_environment() { let (_session, mut turn_context) = make_session_and_context().await; - let first_environment = turn_context.environments[0].clone(); + let first_environment = turn_context.environments.turn_environments[0].clone(); let second_cwd = turn_context.cwd.join("second"); - turn_context.environments.push(TurnEnvironment { - environment_id: "second".to_string(), - environment: Arc::clone(&first_environment.environment), - cwd: second_cwd.clone(), - shell: first_environment.shell.clone(), - }); + turn_context + .environments + .turn_environments + .push(TurnEnvironment { + environment_id: "second".to_string(), + environment: Arc::clone(&first_environment.environment), + cwd: second_cwd.clone(), + shell: first_environment.shell.clone(), + }); assert_eq!( turn_context - .primary_environment() + .environments + .primary() .expect("primary environment") .environment_id, first_environment.environment_id @@ -4502,14 +4510,18 @@ async fn primary_environment_uses_first_turn_environment() { assert_eq!( turn_context .environments + .turn_environments .iter() .find(|environment| environment.environment_id == "second") .expect("second environment") .cwd, second_cwd ); - assert_eq!(turn_context.environments.len(), 2); - assert_eq!(turn_context.environments[1].cwd, second_cwd); + assert_eq!(turn_context.environments.turn_environments.len(), 2); + assert_eq!( + turn_context.environments.turn_environments[1].cwd, + second_cwd + ); } #[tokio::test] @@ -4527,8 +4539,8 @@ async fn empty_turn_environments_clear_primary_environment() { .await .expect("turn should start"); - assert!(turn_context.primary_environment().is_none()); - assert!(turn_context.environments.is_empty()); + assert!(turn_context.environments.primary().is_none()); + assert!(turn_context.environments.turn_environments.is_empty()); assert_eq!(turn_context.cwd, session.get_config().await.cwd); assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a87d2ab178..a68b5396a0 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::GhostSnapshotConfig; +use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::models::AdditionalPermissionProfile; @@ -60,7 +61,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, - pub(crate) environments: Vec, + pub(crate) environments: ResolvedTurnEnvironments, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. @@ -106,10 +107,6 @@ impl TurnContext { self.permission_profile.network_sandbox_policy() } - pub(crate) fn primary_environment(&self) -> Option<&TurnEnvironment> { - self.environments.first() - } - pub(crate) fn sandbox_policy(&self) -> SandboxPolicy { let file_system_sandbox_policy = self.file_system_sandbox_policy(); let network_sandbox_policy = self.network_sandbox_policy(); @@ -198,7 +195,7 @@ impl TurnContext { .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) - .with_has_environment(self.tools_config.has_environment) + .with_environment_mode(self.tools_config.environment_mode) .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) @@ -435,7 +432,7 @@ impl Session { model_info: ModelInfo, models_manager: &SharedModelsManager, network: Option, - environments: Vec, + environments: ResolvedTurnEnvironments, cwd: AbsolutePathBuf, sub_id: String, skills_outcome: Arc, @@ -476,7 +473,9 @@ impl Session { ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_has_environment(!environments.is_empty()) + .with_environment_mode(ToolEnvironmentMode::from_count( + environments.turn_environments.len(), + )) .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) @@ -647,12 +646,11 @@ impl Session { fn resolve_turn_environments( &self, environments: &[TurnEnvironmentSelection], - ) -> CodexResult> { + ) -> CodexResult { crate::environment_selection::resolve_environment_selections( self.services.environment_manager.as_ref(), environments, ) - .map(|resolved| resolved.turn_environments) } async fn new_turn_from_configuration( @@ -660,9 +658,9 @@ impl Session { sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, - turn_environments: Vec, + turn_environments: ResolvedTurnEnvironments, ) -> Arc { - let primary_turn_environment = turn_environments.first(); + let primary_turn_environment = turn_environments.primary(); let cwd = primary_turn_environment .map(|turn_environment| turn_environment.cwd.clone()) .unwrap_or_else(|| session_configuration.cwd.clone()); @@ -769,7 +767,7 @@ impl Session { Ok(turn_environments) => turn_environments, Err(err) => { warn!("failed to resolve stored session environments: {err}"); - Vec::new() + ResolvedTurnEnvironments::default() } }; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index eb7419076d..927f45279e 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1100,7 +1100,7 @@ impl ThreadManagerState { } let environment_selections = resolve_environment_selections(self.environment_manager.as_ref(), &environments)?; - let watch_registration = match environment_selections.primary_turn_environment() { + let watch_registration = match environment_selections.primary() { Some(turn_environment) if !turn_environment.environment.is_remote() => { self.skills_watcher .register_config( diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 75dcc86ae3..6c53452184 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -444,9 +444,15 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { .new_turn_with_sub_id("resume-turn".to_string(), SessionSettingsUpdate::default()) .await .expect("build resumed turn context"); - assert_eq!(resumed_turn.environments.len(), 1); - assert_eq!(resumed_turn.environments[0].cwd, default_cwd); - assert_ne!(resumed_turn.environments[0].cwd, selected_cwd); + assert_eq!(resumed_turn.environments.turn_environments.len(), 1); + assert_eq!( + resumed_turn.environments.turn_environments[0].cwd, + default_cwd + ); + assert_ne!( + resumed_turn.environments.turn_environments[0].cwd, + selected_cwd + ); let forked = manager .fork_thread( @@ -465,9 +471,15 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { .new_turn_with_sub_id("fork-turn".to_string(), SessionSettingsUpdate::default()) .await .expect("build forked turn context"); - assert_eq!(forked_turn.environments.len(), 1); - assert_eq!(forked_turn.environments[0].cwd, default_cwd); - assert_ne!(forked_turn.environments[0].cwd, selected_cwd); + assert_eq!(forked_turn.environments.turn_environments.len(), 1); + assert_eq!( + forked_turn.environments.turn_environments[0].cwd, + default_cwd + ); + assert_ne!( + forked_turn.environments.turn_environments[0].cwd, + selected_cwd + ); } #[tokio::test] diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index d5f719febf..b3d9b481de 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -6,7 +6,6 @@ use crate::config::Config; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -541,12 +540,7 @@ async fn run_agent_job_loop( "agent_job:{job_id}" )))), SpawnAgentOptions { - environments: Some( - turn.environments - .iter() - .map(TurnEnvironment::selection) - .collect(), - ), + environments: Some(turn.environments.to_selections()), ..Default::default() }, ) diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 294e161483..e2e020a96a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -363,7 +363,7 @@ impl ToolHandler for ApplyPatchHandler { // Avoid building temporary ExecParams/command vectors; derive directly from inputs. let cwd = turn.cwd.clone(); let command = vec!["apply_patch".to_string(), patch_input.clone()]; - let Some(turn_environment) = turn.primary_environment() else { + let Some(turn_environment) = turn.environments.primary() else { return Err(FunctionCallError::RespondToModel( "apply_patch is unavailable in this session".to_string(), )); @@ -475,7 +475,8 @@ pub(crate) async fn intercept_apply_patch( tool_name: &str, ) -> Result, FunctionCallError> { let sandbox = turn - .primary_environment() + .environments + .primary() .filter(|env| env.environment.is_remote()) .map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None)); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref()) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 777cb9be1c..e36ee171d3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -6,7 +6,6 @@ use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; -use crate::session::turn_context::TurnEnvironment; pub(crate) struct Handler; @@ -83,29 +82,22 @@ impl ToolHandler for Handler { apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; apply_spawn_agent_overrides(&mut config, child_depth); - let result = Box::pin( - session.services.agent_control.spawn_agent_with_metadata( - config, - input_items, - Some(thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - /*task_name*/ None, - )?), - SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), - environments: Some( - turn.environments - .iter() - .map(TurnEnvironment::selection) - .collect(), - ), - }, - ), - ) + let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + /*task_name*/ None, + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + environments: Some(turn.environments.to_selections()), + }, + )) .await .map_err(collab_spawn_error); let (new_thread_id, new_agent_metadata, status) = match &result { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 26b6750c46..509366de2f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -5,7 +5,6 @@ use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; -use crate::session::turn_context::TurnEnvironment; use codex_protocol::AgentPath; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; @@ -118,12 +117,7 @@ impl ToolHandler for Handler { SpawnAgentOptions { fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), fork_mode, - environments: Some( - turn.environments - .iter() - .map(TurnEnvironment::selection) - .collect(), - ), + environments: Some(turn.environments.to_selections()), }, ) .await diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index fb80845bdd..d498f36530 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -412,7 +412,7 @@ impl ShellHandler { } = args; let mut exec_params = exec_params; - let Some(turn_environment) = turn.primary_environment() else { + let Some(turn_environment) = turn.environments.primary() else { return Err(FunctionCallError::RespondToModel( "shell is unavailable in this session".to_string(), )); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 5aec8c8ba5..563ee8d835 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -196,7 +196,7 @@ impl ToolHandler for UnifiedExecHandler { } }; - let Some(turn_environment) = turn.primary_environment() else { + let Some(turn_environment) = turn.environments.primary() else { return Err(FunctionCallError::RespondToModel( "unified exec is unavailable in this session".to_string(), )); diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 43968c82f7..d05807ef78 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -88,7 +88,7 @@ impl ToolHandler for ViewImageHandler { }; let abs_path = turn.resolve_path(Some(args.path)); - let Some(environment) = turn.primary_environment() else { + let Some(environment) = turn.environments.primary() else { return Err(FunctionCallError::RespondToModel( "view_image is unavailable in this session".to_string(), )); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index e720243f2b..fb330fa913 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -191,7 +191,7 @@ impl ToolRuntime for ApplyPatchRuntime { attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, ) -> Result { - let turn_environment = ctx.turn.primary_environment().ok_or_else(|| { + let turn_environment = ctx.turn.environments.primary().ok_or_else(|| { ToolError::Rejected("apply_patch is unavailable in this session".to_string()) })?; let started_at = Instant::now(); diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5206168230..1bc5d72127 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -254,7 +254,8 @@ impl<'a> ToolRuntime for UnifiedExecRunt } let environment_is_remote = ctx .turn - .primary_environment() + .environments + .primary() .is_some_and(|turn_environment| turn_environment.environment.is_remote()); let command = if environment_is_remote { base_command.to_vec() @@ -292,7 +293,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt .await? { Some(prepared) => { - let Some(turn_environment) = ctx.turn.primary_environment() else { + let Some(turn_environment) = ctx.turn.environments.primary() else { return Err(ToolError::Rejected( "exec_command is unavailable in this session".to_string(), )); @@ -337,7 +338,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt .env_for(command, options, managed_network) .map_err(|err| ToolError::Codex(err.into()))?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); - let Some(turn_environment) = ctx.turn.primary_environment() else { + let Some(turn_environment) = ctx.turn.environments.primary() else { return Err(ToolError::Rejected( "exec_command is unavailable in this session".to_string(), )); diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 4420f11e3c..f8a1480af8 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -96,7 +96,8 @@ async fn exec_command_with_tty( &request, tty, Box::new(NoopSpawnLifecycle), - turn.primary_environment() + turn.environments + .primary() .expect("turn environment") .environment .as_ref(), @@ -594,7 +595,8 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() let remote_test_env = remote_test_env().await?; let (_, mut turn) = make_session_and_context().await; - turn.environments[0].environment = Arc::new(remote_test_env.environment().clone()); + turn.environments.turn_environments[0].environment = + Arc::new(remote_test_env.environment().clone()); let request = test_exec_request( &turn, @@ -612,7 +614,8 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() Box::new(TestSpawnLifecycle { inherited_fds: vec![42], }), - turn.primary_environment() + turn.environments + .primary() .expect("turn environment") .environment .as_ref(), diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 40e5b03468..387d0394a0 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -109,6 +109,7 @@ pub use responses_api::mcp_tool_to_deferred_responses_api_tool; pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_config::ShellCommandBackendConfig; +pub use tool_config::ToolEnvironmentMode; pub use tool_config::ToolUserShellType; pub use tool_config::ToolsConfig; pub use tool_config::ToolsConfigParams; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 32ee9e1e5c..f2fc402cc1 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -88,7 +88,7 @@ pub struct ToolsConfig { pub shell_type: ConfigShellToolType, pub shell_command_backend: ShellCommandBackendConfig, pub unified_exec_shell_mode: UnifiedExecShellMode, - pub has_environment: bool, + pub environment_mode: ToolEnvironmentMode, pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, @@ -129,6 +129,27 @@ pub struct ToolsConfigParams<'a> { pub windows_sandbox_level: WindowsSandboxLevel, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolEnvironmentMode { + None, + Single, + Multiple, +} + +impl ToolEnvironmentMode { + pub fn from_count(count: usize) -> Self { + match count { + 0 => Self::None, + 1 => Self::Single, + _ => Self::Multiple, + } + } + + pub fn has_environment(self) -> bool { + !matches!(self, Self::None) + } +} + impl ToolsConfig { pub fn new(params: &ToolsConfigParams<'_>) -> Self { let ToolsConfigParams { @@ -205,7 +226,7 @@ impl ToolsConfig { shell_type, shell_command_backend, unified_exec_shell_mode: UnifiedExecShellMode::Direct, - has_environment: true, + environment_mode: ToolEnvironmentMode::Single, allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, @@ -306,8 +327,8 @@ impl ToolsConfig { self } - pub fn with_has_environment(mut self, has_environment: bool) -> Self { - self.has_environment = has_environment; + pub fn with_environment_mode(mut self, environment_mode: ToolEnvironmentMode) -> Self { + self.environment_mode = environment_mode; self } diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index c00c7c8b13..594ab2ddcd 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -135,7 +135,7 @@ pub fn build_tool_registry_plan( ); } - if config.has_environment { + if config.environment_mode.has_environment() { match &config.shell_type { ConfigShellToolType::Default => { plan.push_spec( @@ -184,7 +184,9 @@ pub fn build_tool_registry_plan( } } - if config.has_environment && config.shell_type != ConfigShellToolType::Disabled { + if config.environment_mode.has_environment() + && config.shell_type != ConfigShellToolType::Disabled + { plan.register_handler("shell", ToolHandlerKind::Shell); plan.register_handler("container.exec", ToolHandlerKind::Shell); plan.register_handler("local_shell", ToolHandlerKind::Shell); @@ -324,7 +326,7 @@ pub fn build_tool_registry_plan( ); } - if config.has_environment + if config.environment_mode.has_environment() && let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { @@ -346,7 +348,7 @@ pub fn build_tool_registry_plan( plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch); } - if config.has_environment + if config.environment_mode.has_environment() && config .experimental_supported_tools .iter() @@ -393,7 +395,7 @@ pub fn build_tool_registry_plan( ); } - if config.has_environment { + if config.environment_mode.has_environment() { plan.push_spec( create_view_image_tool(ViewImageToolOptions { can_request_original_image_detail: config.can_request_original_image_detail, diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 71aef8b1a7..6ff356a615 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -11,6 +11,7 @@ use crate::ResponsesApiNamespaceTool; use crate::ResponsesApiTool; use crate::ResponsesApiWebSearchFilters; use crate::ResponsesApiWebSearchUserLocation; +use crate::ToolEnvironmentMode; use crate::ToolHandlerSpec; use crate::ToolName; use crate::ToolNamespace; @@ -544,7 +545,7 @@ fn disabled_environment_omits_environment_backed_tools() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }) - .with_has_environment(/*has_environment*/ false); + .with_environment_mode(ToolEnvironmentMode::None); tools_config .experimental_supported_tools .push("list_dir".to_string()); From 5d5500650b3ff375b3a5b5ea5caafea0f81a4cce Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 4 May 2026 11:40:00 -0700 Subject: [PATCH 2/7] Fix Windows PTY teardown by preserving ConPTY ownership (#20685) ## Why On Windows, background terminals could stay visible after their shell process had already exited. The elevated runner waits for the PTY output reader to reach EOF before it sends the final exit message, but the ConPTY helper was reducing ownership down to raw handles too early. That left the pseudoconsole's borrowed pipe handles alive past teardown, so EOF never propagated and the session stayed `running`. ## What changed - change `utils/pty/src/win/conpty.rs` to hand off owned ConPTY resources instead of leaking only raw handles - make `windows-sandbox-rs/src/conpty/mod.rs` keep the pseudoconsole owner and the backing pipe handles together until teardown - update the elevated runner and the legacy unified-exec backend to keep that `ConptyInstance` alive, take only the specific pipe handles they need, and drop the owner at teardown instead of trying to close a detached pseudoconsole handle later ## Testing - desktop app in `Auto-review`: 11 x `cmd /c "ping -n 3 google.com"` all exited cleanly and did not accumulate in the UI - desktop app in `Auto-review`: 5 x `cmd /c "ping -n 30 google.com"` appeared in the UI and drained back out on their own --- codex-rs/utils/pty/src/lib.rs | 2 + codex-rs/utils/pty/src/win/conpty.rs | 16 +++--- codex-rs/utils/pty/src/win/mod.rs | 1 + codex-rs/windows-sandbox-rs/src/conpty/mod.rs | 55 +++++++++++-------- .../src/elevated/command_runner_win.rs | 27 ++++----- codex-rs/windows-sandbox-rs/src/lib.rs | 2 + .../src/unified_exec/backends/legacy.rs | 24 ++++---- 7 files changed, 70 insertions(+), 57 deletions(-) diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index f524156651..39fc9b5522 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -34,4 +34,6 @@ pub use pty::conpty_supported; /// Spawn a process attached to a PTY for interactive use. pub use pty::spawn_process as spawn_pty_process; #[cfg(windows)] +pub use win::PsuedoCon; +#[cfg(windows)] pub use win::conpty::RawConPty; diff --git a/codex-rs/utils/pty/src/win/conpty.rs b/codex-rs/utils/pty/src/win/conpty.rs index ae490160ae..c147b6d106 100644 --- a/codex-rs/utils/pty/src/win/conpty.rs +++ b/codex-rs/utils/pty/src/win/conpty.rs @@ -30,8 +30,8 @@ use portable_pty::PtySystem; use portable_pty::SlavePty; use portable_pty::cmdbuilder::CommandBuilder; use std::mem::ManuallyDrop; -use std::os::windows::io::AsRawHandle; use std::os::windows::io::RawHandle; +use std::ptr; use std::sync::Arc; use std::sync::Mutex; use winapi::um::wincon::COORD; @@ -82,13 +82,15 @@ impl RawConPty { self.con.raw_handle() } - pub fn into_raw_handles(self) -> (RawHandle, RawHandle, RawHandle) { + pub fn into_handles(self) -> (PsuedoCon, FileDescriptor, FileDescriptor) { let me = ManuallyDrop::new(self); - ( - me.con.raw_handle(), - me.input_write.as_raw_handle(), - me.output_read.as_raw_handle(), - ) + unsafe { + ( + ptr::read(&me.con), + ptr::read(&me.input_write), + ptr::read(&me.output_read), + ) + } } } diff --git a/codex-rs/utils/pty/src/win/mod.rs b/codex-rs/utils/pty/src/win/mod.rs index 33b6e52fbd..cfc53cb51e 100644 --- a/codex-rs/utils/pty/src/win/mod.rs +++ b/codex-rs/utils/pty/src/win/mod.rs @@ -49,6 +49,7 @@ mod procthreadattr; mod psuedocon; pub use conpty::ConPtySystem; +pub use psuedocon::PsuedoCon; pub use psuedocon::conpty_supported; #[derive(Debug)] diff --git a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs index 54d1f34281..cd839952b5 100644 --- a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs @@ -12,15 +12,16 @@ use crate::winutil::format_last_error; use crate::winutil::quote_windows_arg; use crate::winutil::to_wide; use anyhow::Result; +use codex_utils_pty::PsuedoCon; use codex_utils_pty::RawConPty; use std::collections::HashMap; use std::ffi::c_void; +use std::os::windows::io::IntoRawHandle; use std::path::Path; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; -use windows_sys::Win32::System::Console::ClosePseudoConsole; use windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT; use windows_sys::Win32::System::Threading::CreateProcessAsUserW; use windows_sys::Win32::System::Threading::EXTENDED_STARTUPINFO_PRESENT; @@ -32,10 +33,10 @@ use crate::process::make_env_block; /// Owns a ConPTY handle and its backing pipe handles. pub struct ConptyInstance { - pub hpc: HANDLE, - pub input_write: HANDLE, - pub output_read: HANDLE, - desktop: Option, + pseudoconsole: Option, + input_write: HANDLE, + output_read: HANDLE, + _desktop: Option, } impl Drop for ConptyInstance { @@ -47,19 +48,24 @@ impl Drop for ConptyInstance { if self.output_read != 0 && self.output_read != INVALID_HANDLE_VALUE { CloseHandle(self.output_read); } - if self.hpc != 0 && self.hpc != INVALID_HANDLE_VALUE { - ClosePseudoConsole(self.hpc); - } } + let _ = self.pseudoconsole.take(); } } impl ConptyInstance { - /// Consume the instance and return raw handles without closing them. - pub fn into_raw(self) -> (HANDLE, HANDLE, HANDLE, Option) { - let me = std::mem::ManuallyDrop::new(self); - let desktop = unsafe { std::ptr::read(&me.desktop) }; - (me.hpc, me.input_write, me.output_read, desktop) + pub fn raw_handle(&self) -> Option { + self.pseudoconsole + .as_ref() + .map(|pseudoconsole| pseudoconsole.raw_handle() as HANDLE) + } + + pub fn take_input_write(&mut self) -> HANDLE { + std::mem::replace(&mut self.input_write, 0) + } + + pub fn take_output_read(&mut self) -> HANDLE { + std::mem::replace(&mut self.output_read, 0) } } @@ -70,13 +76,13 @@ impl ConptyInstance { #[allow(dead_code)] pub fn create_conpty(cols: i16, rows: i16) -> Result { let raw = RawConPty::new(cols, rows)?; - let (hpc, input_write, output_read) = raw.into_raw_handles(); + let (pseudoconsole, input_write, output_read) = raw.into_handles(); Ok(ConptyInstance { - hpc: hpc as HANDLE, - input_write: input_write as HANDLE, - output_read: output_read as HANDLE, - desktop: None, + pseudoconsole: Some(pseudoconsole), + input_write: input_write.into_raw_handle() as HANDLE, + output_read: output_read.into_raw_handle() as HANDLE, + _desktop: None, }) } @@ -109,15 +115,16 @@ pub fn spawn_conpty_process_as_user( si.StartupInfo.lpDesktop = desktop.startup_info_desktop(); let raw = RawConPty::new(/*cols*/ 80, /*rows*/ 24)?; - let (hpc, input_write, output_read) = raw.into_raw_handles(); + let (pseudoconsole, input_write, output_read) = raw.into_handles(); + let hpc = pseudoconsole.raw_handle() as HANDLE; let conpty = ConptyInstance { - hpc: hpc as HANDLE, - input_write: input_write as HANDLE, - output_read: output_read as HANDLE, - desktop: Some(desktop), + pseudoconsole: Some(pseudoconsole), + input_write: input_write.into_raw_handle() as HANDLE, + output_read: output_read.into_raw_handle() as HANDLE, + _desktop: Some(desktop), }; let mut attrs = ProcThreadAttributeList::new(/*attr_count*/ 1)?; - attrs.set_pseudoconsole(conpty.hpc)?; + attrs.set_pseudoconsole(hpc)?; si.lpAttributeList = attrs.as_mut_ptr(); let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; diff --git a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs index b908e7a4ee..80e67044e8 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs @@ -15,7 +15,6 @@ use anyhow::Result; use codex_windows_sandbox::ErrorPayload; use codex_windows_sandbox::ExitPayload; use codex_windows_sandbox::FramedMessage; -use codex_windows_sandbox::LaunchDesktop; use codex_windows_sandbox::LocalSid; use codex_windows_sandbox::Message; use codex_windows_sandbox::OutputPayload; @@ -57,7 +56,6 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; use windows_sys::Win32::System::Console::COORD; -use windows_sys::Win32::System::Console::ClosePseudoConsole; use windows_sys::Win32::System::Console::ResizePseudoConsole; use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; @@ -87,8 +85,8 @@ struct IpcSpawnedProcess { stdout_handle: HANDLE, stderr_handle: HANDLE, stdin_handle: Option, + conpty_owner: Option, hpc_handle: Option, - _desktop_owner: Option, _pipe_handles: Option, } @@ -263,11 +261,11 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { let effective_cwd = effective_cwd(&req.cwd, Some(log_dir.as_path())); + let mut conpty_owner = None; let mut hpc_handle: Option = None; - let mut desktop_owner = None; let mut pipe_handles = None; let (pi, stdout_handle, stderr_handle, stdin_handle) = if req.tty { - let (pi, conpty) = codex_windows_sandbox::spawn_conpty_process_as_user( + let (pi, mut conpty) = codex_windows_sandbox::spawn_conpty_process_as_user( h_token.raw(), &req.command, &effective_cwd, @@ -275,9 +273,10 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { req.use_private_desktop, Some(log_dir.as_path()), )?; - let (hpc, input_write, output_read, desktop) = conpty.into_raw(); - hpc_handle = Some(hpc); - desktop_owner = desktop; + hpc_handle = conpty.raw_handle(); + let input_write = conpty.take_input_write(); + let output_read = conpty.take_output_read(); + conpty_owner = Some(conpty); let stdin_handle = if req.stdin_open { Some(input_write) } else { @@ -323,8 +322,8 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { stdout_handle, stderr_handle, stdin_handle, + conpty_owner, hpc_handle, - _desktop_owner: desktop_owner, _pipe_handles: pipe_handles, }) } @@ -526,6 +525,7 @@ pub fn main() -> Result<()> { let pi = ipc_spawn.pi; let stdout_handle = ipc_spawn.stdout_handle; let stderr_handle = ipc_spawn.stderr_handle; + let mut conpty_owner = ipc_spawn.conpty_owner; let stdin_handle = ipc_spawn.stdin_handle; let hpc_handle = Arc::new(StdMutex::new(ipc_spawn.hpc_handle)); @@ -605,13 +605,10 @@ pub fn main() -> Result<()> { } } - if let Ok(mut guard) = hpc_handle.lock() - && let Some(hpc) = guard.take() - { - unsafe { - ClosePseudoConsole(hpc); - } + if let Ok(mut guard) = hpc_handle.lock() { + let _ = guard.take(); } + drop(conpty_owner.take()); let _ = out_thread.join(); if let Some(thread) = err_thread { diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 16b47f2933..522f8926d5 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -97,6 +97,8 @@ pub use cap::load_or_create_cap_sids; #[cfg(target_os = "windows")] pub use cap::workspace_cap_sid_for_cwd; #[cfg(target_os = "windows")] +pub use conpty::ConptyInstance; +#[cfg(target_os = "windows")] pub use conpty::spawn_conpty_process_as_user; #[cfg(target_os = "windows")] pub use desktop::LaunchDesktop; diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index ba1f15a3be..8458d5c8c3 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -1,6 +1,7 @@ use super::windows_common::finish_driver_spawn; use super::windows_common::normalize_windows_tty_input; use crate::acl::revoke_ace; +use crate::conpty::ConptyInstance; use crate::conpty::spawn_conpty_process_as_user; use crate::desktop::LaunchDesktop; use crate::logging::log_failure; @@ -33,7 +34,6 @@ use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Storage::FileSystem::WriteFile; use windows_sys::Win32::System::Console::COORD; -use windows_sys::Win32::System::Console::ClosePseudoConsole; use windows_sys::Win32::System::Console::ResizePseudoConsole; use windows_sys::Win32::System::Threading::GetExitCodeProcess; use windows_sys::Win32::System::Threading::INFINITE; @@ -48,6 +48,7 @@ struct LegacyProcessHandles { output_join: std::thread::JoinHandle<()>, writer_handle: tokio::task::JoinHandle<()>, hpc: Option, + conpty_owner: Option, token_handle: HANDLE, desktop: Option, } @@ -66,8 +67,8 @@ fn spawn_legacy_process( writer_rx: mpsc::Receiver>, logs_base_dir: Option<&Path>, ) -> Result { - let (pi, output_join, writer_handle, hpc, desktop) = if tty { - let (pi, conpty) = spawn_conpty_process_as_user( + let (pi, output_join, writer_handle, hpc, conpty_owner, desktop) = if tty { + let (pi, mut conpty) = spawn_conpty_process_as_user( h_token, command, cwd, @@ -75,14 +76,14 @@ fn spawn_legacy_process( use_private_desktop, logs_base_dir, )?; - let (hpc, input_write, output_read, desktop) = conpty.into_raw(); - let output_join = spawn_output_reader(output_read, stdout_tx); + let hpc = conpty.raw_handle(); + let output_join = spawn_output_reader(conpty.take_output_read(), stdout_tx); let writer_handle = spawn_input_writer( - Some(input_write), + Some(conpty.take_input_write()), writer_rx, /*normalize_newlines*/ true, ); - (pi, output_join, writer_handle, Some(hpc), desktop) + (pi, output_join, writer_handle, hpc, Some(conpty), None) } else { let pipe_handles = spawn_process_with_pipes( h_token, @@ -120,6 +121,7 @@ fn spawn_legacy_process( output_join, writer_handle, None, + None, Some(pipe_handles.desktop), ) }; @@ -128,6 +130,7 @@ fn spawn_legacy_process( output_join, writer_handle, hpc, + conpty_owner, token_handle: h_token, desktop, }) @@ -328,6 +331,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( output_join, writer_handle, hpc, + mut conpty_owner, token_handle, desktop, } = match spawn_legacy_process( @@ -386,12 +390,10 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( } if let Some(hpc) = hpc_for_wait && let Ok(mut guard) = hpc.lock() - && let Some(hpc) = guard.take() { - unsafe { - ClosePseudoConsole(hpc); - } + let _ = guard.take(); } + drop(conpty_owner.take()); unsafe { if token_handle != 0 && token_handle != INVALID_HANDLE_VALUE { CloseHandle(token_handle); From 0035d7bd18a2499ea2ebfa78dd48af30ec2bffef Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 4 May 2026 11:40:03 -0700 Subject: [PATCH 3/7] Add stdio exec-server listener (#20663) ## Why This stack adds configured exec-server environments, including environments reached over stdio. Before client-side stdio transports or config can use that path, the exec-server binary itself needs a first-class stdio listen mode so it can speak the same JSON-RPC protocol over stdin/stdout that it already speaks over websockets. **Stack position:** this is PR 1 of 5. It is the server-side transport foundation for the stack. ## What Changed - Accept `stdio` and `stdio://` for `codex exec-server --listen`. - Promote the existing stdio `JsonRpcConnection` helper from test-only code into normal exec-server transport code. - Add parse coverage for stdio listen URLs while preserving the existing websocket default. ## Stack - **1. This PR:** https://github.com/openai/codex/pull/20663 - Add stdio exec-server listener - 2. https://github.com/openai/codex/pull/20664 - Add stdio exec-server client transport - 3. https://github.com/openai/codex/pull/20665 - Make environment providers own default selection - 4. https://github.com/openai/codex/pull/20666 - Add CODEX_HOME environments TOML provider - 5. https://github.com/openai/codex/pull/20667 - Load configured environments from CODEX_HOME Split from original draft: https://github.com/openai/codex/pull/20508 ## Validation Not run locally; this was split out of the original draft stack. --------- Co-authored-by: Codex --- codex-rs/cli/src/main.rs | 2 +- codex-rs/exec-server/src/connection.rs | 6 - codex-rs/exec-server/src/server/transport.rs | 61 +++++++- .../exec-server/src/server/transport_tests.rs | 140 ++++++++++++++++-- 4 files changed, 183 insertions(+), 26 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7b6e7448d4..18b811bc1d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -446,7 +446,7 @@ struct AppServerCommand { #[derive(Debug, Parser)] struct ExecServerCommand { - /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default). + /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. #[arg( long = "listen", value_name = "URL", diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index 21eac6b4c5..71f4f31059 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -8,13 +8,9 @@ use tokio::sync::watch; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::Message; -#[cfg(test)] use tokio::io::AsyncBufReadExt; -#[cfg(test)] use tokio::io::AsyncWriteExt; -#[cfg(test)] use tokio::io::BufReader; -#[cfg(test)] use tokio::io::BufWriter; pub(crate) const CHANNEL_CAPACITY: usize = 128; @@ -34,7 +30,6 @@ pub(crate) struct JsonRpcConnection { } impl JsonRpcConnection { - #[cfg(test)] pub(crate) fn from_stdio(reader: R, writer: W, connection_label: String) -> Self where R: AsyncRead + Unpin + Send + 'static, @@ -298,7 +293,6 @@ async fn send_malformed_message( .await; } -#[cfg(test)] async fn write_jsonrpc_line_message( writer: &mut BufWriter, message: &JSONRPCMessage, diff --git a/codex-rs/exec-server/src/server/transport.rs b/codex-rs/exec-server/src/server/transport.rs index b8a5a086b6..d284bf64bb 100644 --- a/codex-rs/exec-server/src/server/transport.rs +++ b/codex-rs/exec-server/src/server/transport.rs @@ -1,5 +1,8 @@ use std::io::Write as _; use std::net::SocketAddr; +use tokio::io; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; use tokio::net::TcpListener; use tokio_tungstenite::accept_async; use tracing::warn; @@ -10,6 +13,12 @@ use crate::server::processor::ConnectionProcessor; pub const DEFAULT_LISTEN_URL: &str = "ws://127.0.0.1:0"; +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) enum ExecServerListenTransport { + WebSocket(SocketAddr), + Stdio, +} + #[derive(Debug, Clone, Eq, PartialEq)] pub enum ExecServerListenUrlParseError { UnsupportedListenUrl(String), @@ -21,7 +30,7 @@ impl std::fmt::Display for ExecServerListenUrlParseError { match self { ExecServerListenUrlParseError::UnsupportedListenUrl(listen_url) => write!( f, - "unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT`" + "unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT` or `stdio`" ), ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url) => write!( f, @@ -35,11 +44,18 @@ impl std::error::Error for ExecServerListenUrlParseError {} pub(crate) fn parse_listen_url( listen_url: &str, -) -> Result { +) -> Result { + if matches!(listen_url, "stdio" | "stdio://") { + return Ok(ExecServerListenTransport::Stdio); + } + if let Some(socket_addr) = listen_url.strip_prefix("ws://") { - return socket_addr.parse::().map_err(|_| { - ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url.to_string()) - }); + return socket_addr + .parse::() + .map(ExecServerListenTransport::WebSocket) + .map_err(|_| { + ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url.to_string()) + }); } Err(ExecServerListenUrlParseError::UnsupportedListenUrl( @@ -51,8 +67,39 @@ pub(crate) async fn run_transport( listen_url: &str, runtime_paths: ExecServerRuntimePaths, ) -> Result<(), Box> { - let bind_address = parse_listen_url(listen_url)?; - run_websocket_listener(bind_address, runtime_paths).await + match parse_listen_url(listen_url)? { + ExecServerListenTransport::WebSocket(bind_address) => { + run_websocket_listener(bind_address, runtime_paths).await + } + ExecServerListenTransport::Stdio => run_stdio_connection(runtime_paths).await, + } +} + +async fn run_stdio_connection( + runtime_paths: ExecServerRuntimePaths, +) -> Result<(), Box> { + run_stdio_connection_with_io(io::stdin(), io::stdout(), runtime_paths).await +} + +async fn run_stdio_connection_with_io( + reader: R, + writer: W, + runtime_paths: ExecServerRuntimePaths, +) -> Result<(), Box> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let processor = ConnectionProcessor::new(runtime_paths); + tracing::info!("codex-exec-server listening on stdio"); + processor + .run_connection(JsonRpcConnection::from_stdio( + reader, + writer, + "exec-server stdio".to_string(), + )) + .await; + Ok(()) } async fn run_websocket_listener( diff --git a/codex-rs/exec-server/src/server/transport_tests.rs b/codex-rs/exec-server/src/server/transport_tests.rs index bec91c936e..b9787d8a37 100644 --- a/codex-rs/exec-server/src/server/transport_tests.rs +++ b/codex-rs/exec-server/src/server/transport_tests.rs @@ -1,31 +1,127 @@ use std::net::SocketAddr; +use std::time::Duration; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::io::duplex; +use tokio::time::timeout; use super::DEFAULT_LISTEN_URL; +use super::ExecServerListenTransport; use super::parse_listen_url; +use super::run_stdio_connection_with_io; +use crate::ExecServerRuntimePaths; +use crate::protocol::INITIALIZE_METHOD; +use crate::protocol::INITIALIZED_METHOD; +use crate::protocol::InitializeParams; +use crate::protocol::InitializeResponse; #[test] fn parse_listen_url_accepts_default_websocket_url() { - let bind_address = - parse_listen_url(DEFAULT_LISTEN_URL).expect("default listen URL should parse"); + let transport = parse_listen_url(DEFAULT_LISTEN_URL).expect("default listen URL should parse"); assert_eq!( - bind_address, - "127.0.0.1:0" - .parse::() - .expect("valid socket address") + transport, + ExecServerListenTransport::WebSocket( + "127.0.0.1:0" + .parse::() + .expect("valid socket address") + ) ); } +#[test] +fn parse_listen_url_accepts_stdio() { + let transport = parse_listen_url("stdio").expect("stdio listen URL should parse"); + assert_eq!(transport, ExecServerListenTransport::Stdio); +} + +#[test] +fn parse_listen_url_accepts_stdio_url() { + let transport = parse_listen_url("stdio://").expect("stdio listen URL should parse"); + assert_eq!(transport, ExecServerListenTransport::Stdio); +} + +#[tokio::test] +async fn stdio_listen_transport_serves_initialize() { + let transport = parse_listen_url("stdio").expect("stdio listen URL should parse"); + let ExecServerListenTransport::Stdio = transport else { + panic!("expected stdio listen transport, got {transport:?}"); + }; + + let (mut client_writer, server_reader) = duplex(1 << 20); + let (server_writer, client_reader) = duplex(1 << 20); + let server_task = tokio::spawn(run_stdio_connection_with_io( + server_reader, + server_writer, + test_runtime_paths(), + )); + let mut client_lines = BufReader::new(client_reader).lines(); + + let initialize = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: INITIALIZE_METHOD.to_string(), + params: Some( + serde_json::to_value(InitializeParams { + client_name: "exec-server-transport-test".to_string(), + resume_session_id: None, + }) + .expect("initialize params should serialize"), + ), + trace: None, + }); + write_jsonrpc_line(&mut client_writer, &initialize).await; + + let response = timeout(Duration::from_secs(1), client_lines.next_line()) + .await + .expect("initialize response should arrive") + .expect("initialize response read should succeed") + .expect("initialize response should be present"); + let response: JSONRPCMessage = + serde_json::from_str(&response).expect("initialize response should parse"); + let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else { + panic!("expected initialize response, got {response:?}"); + }; + assert_eq!(id, RequestId::Integer(1)); + let initialize_response: InitializeResponse = + serde_json::from_value(result).expect("initialize response should decode"); + assert!( + !initialize_response.session_id.is_empty(), + "initialize should return a session id" + ); + + let initialized = JSONRPCMessage::Notification(JSONRPCNotification { + method: INITIALIZED_METHOD.to_string(), + params: Some(serde_json::to_value(()).expect("initialized params should serialize")), + }); + write_jsonrpc_line(&mut client_writer, &initialized).await; + + drop(client_writer); + drop(client_lines); + timeout(Duration::from_secs(1), server_task) + .await + .expect("stdio transport should finish after client disconnect") + .expect("stdio transport task should join") + .expect("stdio transport should not fail"); +} + #[test] fn parse_listen_url_accepts_websocket_url() { - let bind_address = + let transport = parse_listen_url("ws://127.0.0.1:1234").expect("websocket listen URL should parse"); assert_eq!( - bind_address, - "127.0.0.1:1234" - .parse::() - .expect("valid socket address") + transport, + ExecServerListenTransport::WebSocket( + "127.0.0.1:1234" + .parse::() + .expect("valid socket address") + ) ); } @@ -45,6 +141,26 @@ fn parse_listen_url_rejects_unsupported_url() { parse_listen_url("http://127.0.0.1:1234").expect_err("unsupported scheme should fail"); assert_eq!( err.to_string(), - "unsupported --listen URL `http://127.0.0.1:1234`; expected `ws://IP:PORT`" + "unsupported --listen URL `http://127.0.0.1:1234`; expected `ws://IP:PORT` or `stdio`" ); } + +async fn write_jsonrpc_line(writer: &mut tokio::io::DuplexStream, message: &JSONRPCMessage) { + let encoded = serde_json::to_vec(message).expect("JSON-RPC message should serialize"); + writer + .write_all(&encoded) + .await + .expect("JSON-RPC message should write"); + writer + .write_all(b"\n") + .await + .expect("JSON-RPC newline should write"); +} + +fn test_runtime_paths() -> ExecServerRuntimePaths { + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths") +} From 4d201e340e9ff079ba0d8adf1ee7ece6f829a30b Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Mon, 4 May 2026 11:46:03 -0700 Subject: [PATCH 4/7] state: pass state db handles through consumers (#20561) ## Why SQLite state was still being opened from consumer paths, including lazy `OnceCell`-backed thread-store call sites. That let one process construct multiple state DB connections for the same Codex home, which makes SQLite lock contention and `database is locked` failures much easier to hit. State DB lifetime should be chosen by main-like entrypoints and tests, then passed through explicitly. Consumers should use the supplied `Option` or `StateDbHandle` and keep their existing filesystem fallback or error behavior when no handle is available. The startup path also needs to keep the rollout crate in charge of SQLite state initialization. Opening `codex_state::StateRuntime` directly bypasses rollout metadata backfill, so entrypoints should initialize through `codex_rollout::state_db` and receive a handle only after required rollout backfills have completed. ## What Changed - Initialize the state DB in main-like entrypoints for CLI, TUI, app-server, exec, MCP server, and the thread-manager sample. - Pass `Option` through `ThreadManager`, `LocalThreadStore`, app-server processors, TUI app wiring, rollout listing/recording, personality migration, shell snapshot cleanup, session-name lookup, and memory/device-key consumers. - Remove the lazy local state DB wrapper from the thread store so non-test consumers use only the supplied handle or their existing fallback path. - Make `codex_rollout::state_db::init` the local state startup path: it opens/migrates SQLite, runs rollout metadata backfill when needed, waits for concurrent backfill workers up to a bounded timeout, verifies completion, and then returns the initialized handle. - Keep optional/non-owning SQLite helpers, such as remote TUI local reads, as open-only paths that do not run startup backfill. - Switch app-server startup from direct `codex_state::StateRuntime::init` to the rollout state initializer so app-server cannot skip rollout backfill. - Collapse split rollout lookup/list APIs so callers use the normal methods with an optional state handle instead of `_with_state_db` variants. - Restore `getConversationSummary(ThreadId)` to delegate through `ThreadStore::read_thread` instead of a LocalThreadStore-specific rollout path special case. - Keep DB-backed rollout path lookup keyed on the DB row and file existence, without imposing the filesystem filename convention on existing DB rows. - Verify readable DB-backed rollout paths against `session_meta.id` before returning them, so a stale SQLite row that points at another thread's JSONL falls back to filesystem search and read-repairs the DB row. - Keep `debug prompt-input` filesystem-only so a one-off debug command does not initialize or backfill SQLite state just to print prompt input. - Keep goal-session test Codex homes alive only in the goal-specific helper, rather than leaking tempdirs from the shared session test helper. - Update tests and call sites to pass explicit state handles where DB behavior is expected and explicit `None` where filesystem-only behavior is intended. ## Validation - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-thread-store -p codex-app-server -p codex-core -p codex-tui -p codex-exec -p codex-cli --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout state_db_` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout try_init_ -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo clippy -p codex-rollout --lib -- -D warnings` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store read_thread_falls_back_when_sqlite_path_points_to_another_thread -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core shell_snapshot` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all personality_migration` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find::find_prefers_sqlite_path_by_id -- --nocapture` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core interrupt_accounts_active_goal_before_pausing` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server get_auth_status -- --test-threads=1` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server --lib` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-app-server --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-thread-store -p codex-core -p codex-app-server -p codex-tui -p codex-exec -p codex-cli` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-app-server` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-core` - `just argument-comment-lint -p codex-core` - `just argument-comment-lint -p codex-rollout` Focused coverage added in `codex-rollout`: - `recorder::tests::state_db_init_backfills_before_returning` verifies the rollout metadata row exists before startup init returns. - `state_db::tests::try_init_waits_for_concurrent_startup_backfill` verifies startup waits for another worker to finish backfill instead of disabling the handle for the process. - `state_db::tests::try_init_times_out_waiting_for_stuck_startup_backfill` verifies startup does not hang indefinitely on a stuck backfill lease. - `tests::find_thread_path_accepts_existing_state_db_path_without_canonical_filename` verifies DB-backed lookup accepts valid existing rollout paths even when the filename does not include the thread UUID. - `tests::find_thread_path_falls_back_when_db_path_points_to_another_thread` verifies DB-backed lookup ignores a stale row whose existing path belongs to another thread and read-repairs the row after filesystem fallback. Focused coverage updated in `codex-core`: - `rollout_list_find::find_prefers_sqlite_path_by_id` now uses a DB-preferred rollout file with matching `session_meta.id`, so it still verifies that valid SQLite paths win without depending on stale/empty rollout contents. `cargo test -p codex-app-server thread_list_respects_search_term_filter -- --test-threads=1 --nocapture` was attempted locally but timed out waiting for the app-server test harness `initialize` response before reaching the changed thread-list code path. `bazel test //codex-rs/thread-store:thread-store-unit-tests --test_output=errors` was attempted locally after the thread-store fix, but this container failed before target analysis while fetching `v8+` through BuildBuddy/direct GitHub. The equivalent local crate coverage, including `cargo test -p codex-thread-store`, passes. A plain local `cargo check -p codex-rollout -p codex-app-server --tests` also requires system `libcap.pc` for `codex-linux-sandbox`; the follow-up app-server check above used `CODEX_SKIP_VENDORED_BWRAP=1` in this container. --- codex-rs/app-server-client/src/lib.rs | 7 + codex-rs/app-server/src/in_process.rs | 39 +++- codex-rs/app-server/src/lib.rs | 89 +++++--- codex-rs/app-server/src/message_processor.rs | 16 +- .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 1 - .../device_key_processor.rs | 36 +--- .../request_processors/feedback_processor.rs | 5 +- .../thread_goal_processor.rs | 57 ++++-- .../request_processors/thread_processor.rs | 79 +++---- .../src/request_processors/thread_summary.rs | 8 - .../src/request_processors/turn_processor.rs | 27 ++- .../app-server/tests/suite/v2/mcp_resource.rs | 1 + .../tests/suite/v2/remote_thread_store.rs | 1 + .../tests/suite/v2/thread_archive.rs | 68 ++++--- .../app-server/tests/suite/v2/thread_list.rs | 1 + .../tests/suite/v2/thread_name_websocket.rs | 7 +- .../app-server/tests/suite/v2/thread_read.rs | 3 + .../tests/suite/v2/thread_unarchive.rs | 17 +- codex-rs/cli/src/main.rs | 2 +- codex-rs/core-api/src/lib.rs | 2 + codex-rs/core/src/agent/control.rs | 4 +- codex-rs/core/src/agent/control_tests.rs | 18 +- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/personality_migration.rs | 23 ++- .../core/src/personality_migration_tests.rs | 10 +- codex-rs/core/src/prompt_debug.rs | 6 +- codex-rs/core/src/session/mod.rs | 11 +- codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 74 +++++-- .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/shell_snapshot.rs | 28 ++- codex-rs/core/src/shell_snapshot_tests.rs | 33 ++- codex-rs/core/src/state_db_bridge.rs | 4 +- codex-rs/core/src/stream_events_utils.rs | 11 +- codex-rs/core/src/test_support.rs | 16 ++ codex-rs/core/src/thread_manager.rs | 39 +++- codex-rs/core/src/thread_manager_tests.rs | 29 ++- .../src/tools/handlers/multi_agents_tests.rs | 13 +- codex-rs/core/tests/common/test_codex.rs | 7 +- codex-rs/core/tests/suite/client.rs | 3 +- .../core/tests/suite/personality_migration.rs | 31 +-- .../core/tests/suite/prompt_debug_tests.rs | 1 + .../core/tests/suite/rollout_list_find.rs | 62 ++++-- codex-rs/core/tests/suite/skills.rs | 3 +- codex-rs/exec/src/lib.rs | 16 +- codex-rs/mcp-server/src/lib.rs | 2 + codex-rs/mcp-server/src/message_processor.rs | 5 +- codex-rs/rollout/src/list.rs | 56 +++-- codex-rs/rollout/src/metadata.rs | 17 +- codex-rs/rollout/src/recorder.rs | 18 +- codex-rs/rollout/src/recorder_tests.rs | 89 ++++++++ codex-rs/rollout/src/session_index.rs | 9 +- codex-rs/rollout/src/session_index_tests.rs | 6 +- codex-rs/rollout/src/state_db.rs | 192 +++++++++++++----- codex-rs/rollout/src/state_db_tests.rs | 62 ++++++ codex-rs/rollout/src/tests.rs | 81 +++++++- codex-rs/thread-manager-sample/src/main.rs | 5 +- .../thread-store/src/local/archive_thread.rs | 28 +-- .../thread-store/src/local/list_threads.rs | 17 +- codex-rs/thread-store/src/local/mod.rs | 47 ++--- .../thread-store/src/local/read_thread.rs | 84 ++++---- .../src/local/unarchive_thread.rs | 8 +- .../src/local/update_thread_metadata.rs | 58 +++--- codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/app/event_dispatch.rs | 1 + codex-rs/tui/src/app/session_lifecycle.rs | 2 +- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/app/thread_routing.rs | 2 +- codex-rs/tui/src/app/thread_session_state.rs | 2 +- codex-rs/tui/src/lib.rs | 76 +++++-- codex-rs/tui/src/onboarding/auth.rs | 1 + codex-rs/tui/src/session_resume.rs | 15 +- 74 files changed, 1286 insertions(+), 517 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bbbb109eff..539a1684c5 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -29,6 +29,7 @@ pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; pub use codex_app_server::in_process::InProcessServerEvent; use codex_app_server::in_process::InProcessStartArgs; use codex_app_server::in_process::LogDbLayer; +pub use codex_app_server::in_process::StateDbHandle; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -343,6 +344,8 @@ pub struct InProcessClientStartArgs { pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. pub log_db: Option, + /// Process-wide SQLite state handle shared with the embedded app-server. + pub state_db: Option, /// Environment manager used by core execution and filesystem operations. pub environment_manager: Arc, /// Startup warnings emitted after initialize succeeds. @@ -404,6 +407,7 @@ impl InProcessClientStartArgs { thread_config_loader, feedback: self.feedback, log_db: self.log_db, + state_db: self.state_db, environment_manager: self.environment_manager, config_warnings: self.config_warnings, session_source: self.session_source, @@ -983,6 +987,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -2057,6 +2062,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: environment_manager.clone(), config_warnings: Vec::new(), session_source: SessionSource::Exec, @@ -2096,6 +2102,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Exec, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 57f06e64f0..09d57a60a3 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -86,6 +86,7 @@ use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; use codex_protocol::protocol::SessionSource; +pub use codex_rollout::StateDbHandle; pub use codex_state::log_db::LogDbLayer; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -126,6 +127,8 @@ pub struct InProcessStartArgs { pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. pub log_db: Option, + /// Process-wide SQLite state handle shared with embedded app-server consumers. + pub state_db: Option, /// Environment manager used by core execution and filesystem operations. pub environment_manager: Arc, /// Startup warnings emitted after initialize succeeds. @@ -251,6 +254,8 @@ pub struct InProcessClientHandle { client: InProcessClientSender, event_rx: mpsc::Receiver, runtime_handle: tokio::task::JoinHandle<()>, + #[cfg(test)] + _test_codex_home: Option, } impl InProcessClientHandle { @@ -418,6 +423,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { environment_manager: args.environment_manager, feedback: args.feedback, log_db: args.log_db, + state_db: args.state_db, config_warnings: args.config_warnings, session_source: args.session_source, auth_manager, @@ -717,6 +723,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { client: InProcessClientSender { client_tx }, event_rx, runtime_handle, + #[cfg(test)] + _test_codex_home: None, } } @@ -738,13 +746,22 @@ mod tests { use codex_app_server_protocol::TurnStatus; use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::TempDir; - async fn build_test_config() -> Config { - match ConfigBuilder::default().build().await { + async fn build_test_config(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { Ok(config) => config, - Err(_) => Config::load_default_with_cli_overrides(Vec::new()) - .await - .expect("default config should load"), + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), } } @@ -752,15 +769,21 @@ mod tests { session_source: SessionSource, channel_capacity: usize, ) -> InProcessClientHandle { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config(codex_home.path()).await); + let state_db = codex_rollout::state_db::try_init(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); let args = InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -775,7 +798,9 @@ mod tests { }, channel_capacity, }; - start(args).await.expect("in-process runtime should start") + let mut client = start(args).await.expect("in-process runtime should start"); + client._test_codex_home = Some(codex_home); + client } async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 45caa04a58..4b1723d88c 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -54,6 +54,7 @@ use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; +use codex_rollout::state_db as rollout_state_db; use codex_state::log_db; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -453,23 +454,6 @@ pub async fn run_main_with_transport_options( .await { Ok(config) => { - let effective_toml = config.config_layer_stack.effective_config(); - match effective_toml.try_into() { - Ok(config_toml) => { - if let Err(err) = codex_core::personality_migration::maybe_migrate_personality( - &config.codex_home, - &config_toml, - ) - .await - { - warn!(error = %err, "Failed to run personality migration"); - } - } - Err(err) => { - warn!(error = %err, "Failed to deserialize config for personality migration"); - } - } - let discovered_thread_config_loader = configured_thread_config_loader(&config); config_manager .replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader)); @@ -483,23 +467,70 @@ pub async fn run_main_with_transport_options( } }; let mut config_warnings = Vec::new(); - let config = match config_manager + let (mut config, should_run_personality_migration) = match config_manager .load_latest_config(/*fallback_cwd*/ None) .await { - Ok(config) => config, + Ok(config) => (config, true), Err(err) => { let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); - config_manager.load_default_config().await.map_err(|e| { - std::io::Error::new( - ErrorKind::InvalidData, - format!("error loading default config after config error: {e}"), - ) - })? + ( + config_manager.load_default_config().await.map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading default config after config error: {e}"), + ) + })?, + false, + ) } }; + let state_db_result = rollout_state_db::try_init(&config).await; + let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); + let state_db = state_db_result.ok(); + + if should_run_personality_migration { + let effective_toml = config.config_layer_stack.effective_config(); + match effective_toml.try_into() { + Ok(config_toml) => { + match codex_core::personality_migration::maybe_migrate_personality( + &config.codex_home, + &config_toml, + state_db.clone(), + ) + .await + { + Ok(codex_core::personality_migration::PersonalityMigrationStatus::Applied) => { + config = config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + .map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!( + "error reloading config after personality migration: {err}" + ), + ) + })?; + } + Ok( + codex_core::personality_migration::PersonalityMigrationStatus::SkippedMarker + | codex_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality + | codex_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions, + ) => {} + Err(err) => { + warn!(error = %err, "Failed to run personality migration"); + } + } + } + Err(err) => { + warn!(error = %err, "Failed to deserialize config for personality migration"); + } + } + } + if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { let (path, range) = exec_policy_warning_location(&err); let message = ConfigWarningNotification { @@ -567,13 +598,6 @@ pub async fn run_main_with_transport_options( let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let state_db_result = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ) - .await; - let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); - let state_db = state_db_result.ok(); let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() @@ -745,6 +769,7 @@ pub async fn run_main_with_transport_options( environment_manager, feedback: feedback.clone(), log_db, + state_db: state_db.clone(), config_warnings, session_source, auth_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d444dee33c..b4bce010de 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -72,6 +72,7 @@ use codex_login::auth::ExternalAuthTokens; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; +use codex_rollout::StateDbHandle; use codex_state::log_db::LogDbLayer; use tokio::sync::Mutex; use tokio::sync::Semaphore; @@ -251,6 +252,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) environment_manager: Arc, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, + pub(crate) state_db: Option, pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, @@ -272,6 +274,7 @@ impl MessageProcessor { environment_manager, feedback, log_db, + state_db, config_warnings, session_source, auth_manager, @@ -285,7 +288,7 @@ impl MessageProcessor { // The thread store is intentionally process-scoped. Config reloads can // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. - let thread_store = thread_store_from_config(config.as_ref()); + let thread_store = thread_store_from_config(config.as_ref(), state_db.clone()); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -293,6 +296,7 @@ impl MessageProcessor { environment_manager, Some(analytics_events_client.clone()), Arc::clone(&thread_store), + state_db.clone(), )); thread_manager .plugins_manager() @@ -337,6 +341,7 @@ impl MessageProcessor { Arc::clone(&config), feedback, log_db, + state_db.clone(), ); let git_processor = GitRequestProcessor::new(); let initialize_processor = InitializeRequestProcessor::new( @@ -371,6 +376,7 @@ impl MessageProcessor { outgoing.clone(), Arc::clone(&config), thread_state_manager.clone(), + state_db.clone(), ); let thread_processor = ThreadRequestProcessor::new( auth_manager.clone(), @@ -386,6 +392,7 @@ impl MessageProcessor { thread_watch_manager.clone(), Arc::clone(&thread_list_state_permit), thread_goal_processor.clone(), + state_db.clone(), ); let turn_processor = TurnRequestProcessor::new( auth_manager.clone(), @@ -399,6 +406,7 @@ impl MessageProcessor { thread_state_manager, thread_watch_manager, thread_list_state_permit, + state_db.clone(), ); if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { // Keep plugin startup warmups aligned at app-server startup. @@ -429,11 +437,7 @@ impl MessageProcessor { arg0_paths, config.codex_home.to_path_buf(), ); - let device_key_processor = DeviceKeyRequestProcessor::new( - outgoing.clone(), - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ); + let device_key_processor = DeviceKeyRequestProcessor::new(outgoing.clone(), state_db); let fs_processor = FsRequestProcessor::new( thread_manager .environment_manager() diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 8caf1aaa96..45ea709180 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -290,6 +290,7 @@ async fn build_test_processor( environment_manager: Arc::new(EnvironmentManager::default_for_tests()), feedback: CodexFeedback::new(), log_db: None, + state_db: None, config_warnings: Vec::new(), session_source: SessionSource::VSCode, auth_manager, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index b1c6ab9815..f59ea14402 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -369,7 +369,6 @@ use codex_rmcp_client::perform_oauth_login_return_url; use codex_rollout::EventPersistenceMode; use codex_rollout::is_persisted_rollout_item; use codex_rollout::state_db::StateDbHandle; -use codex_rollout::state_db::get_state_db; use codex_rollout::state_db::reconcile_rollout; use codex_state::StateRuntime; use codex_state::ThreadMetadata; diff --git a/codex-rs/app-server/src/request_processors/device_key_processor.rs b/codex-rs/app-server/src/request_processors/device_key_processor.rs index c469f2544a..ea0a96c2af 100644 --- a/codex-rs/app-server/src/request_processors/device_key_processor.rs +++ b/codex-rs/app-server/src/request_processors/device_key_processor.rs @@ -1,6 +1,5 @@ use std::fmt; use std::future::Future; -use std::path::PathBuf; use std::sync::Arc; use crate::error_code::internal_error; @@ -36,7 +35,6 @@ use codex_device_key::RemoteControlClientEnrollmentAudience; use codex_device_key::RemoteControlClientEnrollmentSignPayload; use codex_state::DeviceKeyBindingRecord; use codex_state::StateRuntime; -use tokio::sync::OnceCell; #[derive(Clone)] pub(crate) struct DeviceKeyRequestProcessor { @@ -47,15 +45,11 @@ pub(crate) struct DeviceKeyRequestProcessor { impl DeviceKeyRequestProcessor { pub(crate) fn new( outgoing: Arc, - sqlite_home: PathBuf, - default_provider: String, + state_db: Option>, ) -> Self { Self { outgoing, - store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new( - sqlite_home, - default_provider, - ))), + store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(state_db))), } } @@ -176,39 +170,25 @@ async fn sign_device_key( } struct StateDeviceKeyBindingStore { - sqlite_home: PathBuf, - default_provider: String, - state_db: OnceCell>, + state_db: Option>, } impl StateDeviceKeyBindingStore { - fn new(sqlite_home: PathBuf, default_provider: String) -> Self { - Self { - sqlite_home, - default_provider, - state_db: OnceCell::new(), - } + fn new(state_db: Option>) -> Self { + Self { state_db } } async fn state_db(&self) -> Result, DeviceKeyError> { - let sqlite_home = self.sqlite_home.clone(); - let default_provider = self.default_provider.clone(); self.state_db - .get_or_try_init(|| async move { - StateRuntime::init(sqlite_home, default_provider) - .await - .map_err(|err| DeviceKeyError::Platform(err.to_string())) - }) - .await - .cloned() + .clone() + .ok_or_else(|| DeviceKeyError::Platform("sqlite state db unavailable".to_string())) } } impl fmt::Debug for StateDeviceKeyBindingStore { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("StateDeviceKeyBindingStore") - .field("sqlite_home", &self.sqlite_home) - .field("default_provider", &self.default_provider) + .field("has_state_db", &self.state_db.is_some()) .finish_non_exhaustive() } } diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 666dcac83a..5e945d7b10 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -7,6 +7,7 @@ pub(crate) struct FeedbackRequestProcessor { config: Arc, feedback: CodexFeedback, log_db: Option, + state_db: Option, } impl FeedbackRequestProcessor { @@ -16,6 +17,7 @@ impl FeedbackRequestProcessor { config: Arc, feedback: CodexFeedback, log_db: Option, + state_db: Option, ) -> Self { Self { auth_manager, @@ -23,6 +25,7 @@ impl FeedbackRequestProcessor { config, feedback, log_db, + state_db, } } @@ -75,7 +78,7 @@ impl FeedbackRequestProcessor { if let Some(log_db) = self.log_db.as_ref() { log_db.flush().await; } - let state_db_ctx = get_state_db(&self.config).await; + let state_db_ctx = self.state_db.clone(); let feedback_thread_ids = match conversation_id { Some(conversation_id) => match self .thread_manager diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index ba9115f150..2e7c9909f0 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -7,6 +7,7 @@ pub(crate) struct ThreadGoalRequestProcessor { outgoing: Arc, config: Arc, thread_state_manager: ThreadStateManager, + state_db: Option, } impl ThreadGoalRequestProcessor { @@ -15,12 +16,14 @@ impl ThreadGoalRequestProcessor { outgoing: Arc, config: Arc, thread_state_manager: ThreadStateManager, + state_db: Option, ) -> Self { Self { thread_manager, outgoing, config, thread_state_manager, + state_db, } } @@ -78,7 +81,7 @@ impl ThreadGoalRequestProcessor { if let Some(state_db) = thread.state_db() { Some(state_db) } else { - open_state_db_for_direct_thread_lookup(&self.config).await + self.state_db.clone() } } else { None @@ -104,12 +107,16 @@ impl ThreadGoalRequestProcessor { "ephemeral thread does not support goals: {thread_id}" )) })?, - None => find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + None => find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, }; reconcile_rollout( Some(&state_db), @@ -247,12 +254,16 @@ impl ThreadGoalRequestProcessor { "ephemeral thread does not support goals: {thread_id}" )) })?, - None => find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + None => find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, }; reconcile_rollout( Some(&state_db), @@ -307,16 +318,20 @@ impl ThreadGoalRequestProcessor { return Ok(state_db); } } else { - find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?; + find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?; } - open_state_db_for_direct_thread_lookup(&self.config) - .await + self.state_db + .clone() .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) } 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 f10652a029..40be4f34ef 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -259,6 +259,7 @@ pub(crate) struct ThreadRequestProcessor { pub(super) thread_watch_manager: ThreadWatchManager, pub(super) thread_list_state_permit: Arc, pub(super) thread_goal_processor: ThreadGoalRequestProcessor, + pub(super) state_db: Option, pub(super) background_tasks: TaskTracker, } @@ -278,6 +279,7 @@ impl ThreadRequestProcessor { thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, thread_goal_processor: ThreadGoalRequestProcessor, + state_db: Option, ) -> Self { Self { auth_manager, @@ -293,6 +295,7 @@ impl ThreadRequestProcessor { thread_watch_manager, thread_list_state_permit, thread_goal_processor, + state_db, background_tasks: TaskTracker::new(), } } @@ -1170,7 +1173,7 @@ impl ThreadRequestProcessor { .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let mut thread_ids = vec![thread_id]; - if let Some(state_db_ctx) = get_state_db(&self.config).await { + if let Some(state_db_ctx) = self.state_db.as_ref() { let descendants = state_db_ctx .list_thread_spawn_descendants(thread_id) .await @@ -1391,14 +1394,10 @@ impl ThreadRequestProcessor { } async fn memory_reset_response_inner(&self) -> Result { - let state_db = StateRuntime::init( - self.config.sqlite_home.clone(), - self.config.model_provider_id.clone(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to open state db for memory reset: {err}")) - })?; + let state_db = self + .state_db + .clone() + .ok_or_else(|| internal_error("sqlite state db unavailable for memory reset"))?; state_db.clear_memory_data().await.map_err(|err| { internal_error(format!("failed to clear memory rows in state db: {err}")) @@ -1445,7 +1444,7 @@ impl ThreadRequestProcessor { let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); let mut state_db_ctx = loaded_thread.as_ref().and_then(|thread| thread.state_db()); if state_db_ctx.is_none() { - state_db_ctx = get_state_db(&self.config).await; + state_db_ctx = self.state_db.clone(); } let Some(state_db_ctx) = state_db_ctx else { return Err(internal_error(format!( @@ -1583,33 +1582,37 @@ impl ThreadRequestProcessor { return Ok(()); } - let rollout_path = - match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string()) - .await + let rollout_path = match find_thread_path_by_id_str( + &self.config.codex_home, + &thread_uuid.to_string(), + self.state_db.as_deref(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => match find_archived_thread_path_by_id_str( + &self.config.codex_home, + &thread_uuid.to_string(), + self.state_db.as_deref(), + ) + .await { Ok(Some(path)) => path, - Ok(None) => match find_archived_thread_path_by_id_str( - &self.config.codex_home, - &thread_uuid.to_string(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - return Err(invalid_request(format!("thread not found: {thread_uuid}"))); - } - Err(err) => { - return Err(internal_error(format!( - "failed to locate archived thread id {thread_uuid}: {err}" - ))); - } - }, + Ok(None) => { + return Err(invalid_request(format!("thread not found: {thread_uuid}"))); + } Err(err) => { return Err(internal_error(format!( - "failed to locate thread id {thread_uuid}: {err}" + "failed to locate archived thread id {thread_uuid}: {err}" ))); } - }; + }, + Err(err) => { + return Err(internal_error(format!( + "failed to locate thread id {thread_uuid}: {err}" + ))); + } + }; reconcile_rollout( Some(state_db_ctx), @@ -2555,7 +2558,7 @@ impl ThreadRequestProcessor { let InitialHistory::Resumed(resumed_history) = thread_history else { return None; }; - let state_db_ctx = get_state_db(&self.config).await?; + let state_db_ctx = self.state_db.clone()?; let persisted_metadata = state_db_ctx .get_thread(resumed_history.conversation_id) .await @@ -2922,7 +2925,9 @@ impl ThreadRequestProcessor { } async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) { - if let Some(title) = title_from_state_db(&self.config, thread_id).await { + if let Some(title) = + title_from_state_db(&self.config, self.state_db.as_ref(), thread_id).await + { set_thread_name_from_title(thread, title); } } @@ -3683,8 +3688,12 @@ async fn read_summary_from_state_db_context_by_thread_id( Some(summary_from_thread_metadata(&metadata)) } -async fn title_from_state_db(config: &Config, thread_id: ThreadId) -> Option { - if let Some(state_db_ctx) = open_state_db_for_direct_thread_lookup(config).await +async fn title_from_state_db( + config: &Config, + state_db_ctx: Option<&StateDbHandle>, + thread_id: ThreadId, +) -> Option { + if let Some(state_db_ctx) = state_db_ctx && let Some(metadata) = state_db_ctx.get_thread(thread_id).await.ok().flatten() && let Some(title) = distinct_title(&metadata) { 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 d528604928..ed2360ed13 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -1,13 +1,5 @@ use super::*; -pub(super) async fn open_state_db_for_direct_thread_lookup( - config: &Config, -) -> Option { - StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) - .await - .ok() -} - pub(crate) async fn read_summary_from_rollout( path: &Path, fallback_provider: &str, 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 cfeaedbdb0..c11b7166e2 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -13,6 +13,7 @@ pub(crate) struct TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, + state_db: Option, } impl TurnRequestProcessor { @@ -29,6 +30,7 @@ impl TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, + state_db: Option, ) -> Self { Self { auth_manager, @@ -42,6 +44,7 @@ impl TurnRequestProcessor { thread_state_manager, thread_watch_manager, thread_list_state_permit, + state_db, } } @@ -891,16 +894,20 @@ impl TurnRequestProcessor { let rollout_path = if let Some(path) = parent_thread.rollout_path() { path } else { - find_thread_path_by_id_str(&self.config.codex_home, &parent_thread_id.to_string()) - .await - .map_err(|err| { - internal_error(format!( - "failed to locate thread id {parent_thread_id}: {err}" - )) - })? - .ok_or_else(|| { - invalid_request(format!("no rollout found for thread id {parent_thread_id}")) - })? + find_thread_path_by_id_str( + &self.config.codex_home, + &parent_thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!( + "failed to locate thread id {parent_thread_id}: {err}" + )) + })? + .ok_or_else(|| { + invalid_request(format!("no rollout found for thread id {parent_thread_id}")) + })? }; let mut config = self.config.as_ref().clone(); diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 3b1a495576..a51f4bbd4e 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -204,6 +204,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index a76caefebb..b04eb12a45 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -80,6 +80,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index 7d884c9a7d..b441a23cb6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -63,7 +63,7 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { rollout_path.display() ); assert!( - find_thread_path_by_id_str(codex_home.path(), &thread.id) + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) .await? .is_none(), "thread id should not be discoverable before rollout materialization" @@ -118,9 +118,10 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { rollout_path.display() ); - let discovered_path = find_thread_path_by_id_str(codex_home.path(), &thread.id) - .await? - .expect("expected rollout path for thread id to exist after materialization"); + let discovered_path = + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .expect("expected rollout path for thread id to exist after materialization"); assert_paths_match_on_disk(&discovered_path, &rollout_path)?; let archive_id = mcp @@ -252,15 +253,23 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> { for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] { assert!( - find_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_none(), + find_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_none(), "expected active rollout for {thread_id} to be archived" ); assert!( - find_archived_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_some(), + find_archived_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_some(), "expected archived rollout for {thread_id} to exist" ); } @@ -322,9 +331,10 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { ) .await?; - let child_rollout_path = find_thread_path_by_id_str(codex_home.path(), &child_id) - .await? - .expect("child rollout path"); + let child_rollout_path = + find_thread_path_by_id_str(codex_home.path(), &child_id, /*state_db_ctx*/ None) + .await? + .expect("child rollout path"); let archived_child_path = codex_home .path() .join(ARCHIVED_SESSIONS_SUBDIR) @@ -381,15 +391,23 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { ); for thread_id in [parent_thread_id, grandchild_thread_id] { assert!( - find_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_none(), + find_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_none(), "expected active rollout for {thread_id} to be archived" ); assert!( - find_archived_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_some(), + find_archived_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_some(), "expected archived rollout for {thread_id} to exist" ); } @@ -455,15 +473,19 @@ async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result< assert_eq!(archived_notification.thread_id, parent_id); assert!( - find_thread_path_by_id_str(codex_home.path(), &parent_id) + find_thread_path_by_id_str(codex_home.path(), &parent_id, /*state_db_ctx*/ None) .await? .is_none(), "parent should be archived even when a descendant is missing" ); assert!( - find_archived_thread_path_by_id_str(codex_home.path(), &parent_id) - .await? - .is_some(), + find_archived_thread_path_by_id_str( + codex_home.path(), + &parent_id, + /*state_db_ctx*/ None, + ) + .await? + .is_some(), "parent should be moved into archived sessions" ); 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 615692d70d..ebaba81852 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -614,6 +614,7 @@ sqlite = true generate_memories: false, }; let repaired_page = codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), &rollout_config, /*page_size*/ 10, /*cursor*/ None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs index b41e2f1d18..6626b7a6cc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -211,9 +211,10 @@ async fn thread_name_update_rollout_count( codex_home: &Path, conversation_id: &str, ) -> Result { - let rollout_path = find_thread_path_by_id_str(codex_home, conversation_id) - .await? - .context("rollout path")?; + let rollout_path = + find_thread_path_by_id_str(codex_home, conversation_id, /*state_db_ctx*/ None) + .await? + .context("rollout path")?; let contents = tokio::fs::read_to_string(rollout_path).await?; Ok(contents .lines() 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 589c7c330a..8c46a5ad95 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -300,6 +300,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), @@ -363,6 +364,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), @@ -447,6 +449,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index b2ae60ae35..588764edb8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -75,9 +75,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ) .await??; - let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id) - .await? - .expect("expected rollout path for thread id to exist"); + let found_rollout_path = + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .expect("expected rollout path for thread id to exist"); assert_paths_match_on_disk(&found_rollout_path, &rollout_path)?; let archive_id = mcp @@ -92,9 +93,13 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result .await??; let _: ThreadArchiveResponse = to_response::(archive_resp)?; - let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id) - .await? - .expect("expected archived rollout path for thread id to exist"); + let archived_path = find_archived_thread_path_by_id_str( + codex_home.path(), + &thread.id, + /*state_db_ctx*/ None, + ) + .await? + .expect("expected archived rollout path for thread id to exist"); let archived_path_display = archived_path.display(); assert!( archived_path.exists(), diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 18b811bc1d..c56055ea2d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1388,7 +1388,7 @@ async fn run_debug_prompt_input_command( }); } - let prompt_input = codex_core::build_prompt_input(config, input).await?; + let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?; println!("{}", serde_json::to_string_pretty(&prompt_input)?); Ok(()) diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index dca169ed2b..aa68656d1b 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -27,6 +27,7 @@ pub use codex_core::ForkSnapshot; pub use codex_core::McpManager; pub use codex_core::NewThread; pub use codex_core::StartThreadOptions; +pub use codex_core::StateDbHandle; pub use codex_core::ThreadManager; pub use codex_core::ThreadShutdownReport; pub use codex_core::config::Config; @@ -37,6 +38,7 @@ pub use codex_core::config::Permissions; pub use codex_core::config::TerminalResizeReflowConfig; pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; +pub use codex_core::init_state_db; pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 2ade6ab25c..1a13c0b00c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -29,7 +29,6 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; -use codex_rollout::state_db; use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_thread_store::ReadThreadParams; use serde::Serialize; @@ -526,6 +525,7 @@ impl AgentControl { let _ = config.features.disable(Feature::Collab); } let state = self.upgrade()?; + let state_db_ctx = state.state_db(); let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -536,7 +536,7 @@ impl AgentControl { agent_nickname: _, }) => { let (resumed_agent_nickname, resumed_agent_role) = - if let Some(state_db_ctx) = state_db::get_state_db(&config).await { + if let Some(state_db_ctx) = state_db_ctx.as_ref() { match state_db_ctx.get_thread(thread_id).await { Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), Ok(None) | Err(_) => (None, None), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7ef2120d5c..b95aad4489 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::CodexThread; +use crate::StateDbHandle; use crate::ThreadManager; use crate::agent::agent_status_from_event; use crate::config::AgentRoleConfig; @@ -7,6 +8,7 @@ use crate::config::Config; use crate::config::ConfigBuilder; use crate::context::ContextualUserFragment; use crate::context::SubagentNotification; +use crate::init_state_db; use assert_matches::assert_matches; use codex_features::Feature; use codex_login::CodexAuth; @@ -84,6 +86,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { struct AgentControlHarness { _home: TempDir, config: Config, + state_db: Option, manager: ThreadManager, control: AgentControl, } @@ -91,16 +94,19 @@ struct AgentControlHarness { impl AgentControlHarness { async fn new() -> Self { let (home, config) = test_config().await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), ); let control = manager.agent_control(); Self { _home: home, config, + state_db, manager, control, } @@ -1537,16 +1543,19 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { .features .enable(Feature::Sqlite) .expect("test config should allow sqlite"); - let manager = ThreadManager::with_models_provider_and_home_for_tests( + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), ); let control = manager.agent_control(); let harness = AgentControlHarness { _home: home, config, + state_db, manager, control, }; @@ -1695,7 +1704,10 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() { .shutdown_live_agent(child_thread_id) .await .expect("child shutdown should succeed"); - let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&harness.config)); + let store = LocalThreadStore::new( + LocalThreadStoreConfig::from_config(&harness.config), + harness.state_db.clone(), + ); store .archive_thread(ArchiveThreadParams { thread_id: child_thread_id, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 721feee834..cf909c5e81 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -145,7 +145,7 @@ pub(crate) mod shell_snapshot; pub mod spawn; pub(crate) mod state_db_bridge; pub use state_db_bridge::StateDbHandle; -pub use state_db_bridge::get_state_db; +pub use state_db_bridge::init_state_db; mod thread_rollout_truncation; mod tools; pub(crate) mod turn_diff_tracker; diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 5227cf07e3..975aecd4af 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -1,6 +1,7 @@ use crate::config::edit::ConfigEditsBuilder; use codex_config::config_toml::ConfigToml; use codex_protocol::config_types::Personality; +use codex_rollout::state_db::StateDbHandle; use codex_thread_store::ListThreadsParams; use codex_thread_store::LocalThreadStore; use codex_thread_store::LocalThreadStoreConfig; @@ -24,6 +25,7 @@ pub enum PersonalityMigrationStatus { pub async fn maybe_migrate_personality( codex_home: &Path, config_toml: &ConfigToml, + state_db: Option, ) -> io::Result { let marker_path = codex_home.join(PERSONALITY_MIGRATION_FILENAME); if tokio::fs::try_exists(&marker_path).await? { @@ -43,7 +45,7 @@ pub async fn maybe_migrate_personality( .or_else(|| config_toml.model_provider.clone()) .unwrap_or_else(|| "openai".to_string()); - if !has_recorded_sessions(codex_home, model_provider_id.as_str()).await? { + if !has_recorded_sessions(codex_home, model_provider_id.as_str(), state_db).await? { create_marker(&marker_path).await?; return Ok(PersonalityMigrationStatus::SkippedNoSessions); } @@ -60,12 +62,19 @@ pub async fn maybe_migrate_personality( Ok(PersonalityMigrationStatus::Applied) } -async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result { - let store = LocalThreadStore::new(LocalThreadStoreConfig { - codex_home: codex_home.to_path_buf(), - sqlite_home: codex_home.to_path_buf(), - default_model_provider_id: default_provider.to_string(), - }); +async fn has_recorded_sessions( + codex_home: &Path, + default_provider: &str, + state_db: Option, +) -> io::Result { + let store = LocalThreadStore::new( + LocalThreadStoreConfig { + codex_home: codex_home.to_path_buf(), + sqlite_home: codex_home.to_path_buf(), + default_model_provider_id: default_provider.to_string(), + }, + state_db, + ); if has_threads(&store, /*archived*/ false).await? { return Ok(true); } diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 4aef53a5c4..28d34bc3c9 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -87,7 +87,7 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> { write_session_with_user_event(temp.path()).await?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); @@ -103,7 +103,7 @@ async fn applies_when_only_archived_sessions_exist_and_no_personality() -> io::R write_archived_session_with_user_event(temp.path()).await?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); @@ -119,7 +119,7 @@ async fn skips_when_marker_exists() -> io::Result<()> { create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); assert!(!temp.path().join("config.toml").exists()); @@ -136,7 +136,7 @@ async fn skips_when_personality_explicit() -> io::Result<()> { .map_err(|err| io::Error::other(format!("failed to write config: {err}")))?; let config_toml = read_config_toml(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!( status, @@ -153,7 +153,7 @@ async fn skips_when_personality_explicit() -> io::Result<()> { async fn skips_when_no_sessions() -> io::Result<()> { let temp = TempDir::new()?; let config_toml = ConfigToml::default(); - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists()); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index d4f3130129..7c6144c10e 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -16,6 +16,7 @@ use crate::config::Config; use crate::session::session::Session; use crate::session::turn::build_prompt; use crate::session::turn::built_tools; +use crate::state_db_bridge::StateDbHandle; use crate::thread_manager::ThreadManager; use crate::thread_manager::thread_store_from_config; @@ -24,6 +25,7 @@ use crate::thread_manager::thread_store_from_config; pub async fn build_prompt_input( mut config: Config, input: Vec, + state_db: Option, ) -> CodexResult> { config.ephemeral = true; @@ -35,13 +37,15 @@ pub async fn build_prompt_input( config.codex_linux_sandbox_exe.clone(), )?; + let thread_store = thread_store_from_config(&config, state_db.clone()); let thread_manager = ThreadManager::new( &config, Arc::clone(&auth_manager), SessionSource::Exec, Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store, + state_db.clone(), ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cb8c21c4da..b697b8e623 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -556,7 +556,15 @@ impl Codex { }; match thread_id { Some(thread_id) => { - let state_db_ctx = state_db::get_state_db(&config).await; + let state_db_ctx = if config.ephemeral { + None + } else if let Some(local_store) = + thread_store.as_any().downcast_ref::() + { + local_store.state_db().await + } else { + None + }; state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn") .await } @@ -1298,6 +1306,7 @@ impl Session { self.services.user_shell.as_ref().clone(), self.services.shell_snapshot_tx.clone(), self.services.session_telemetry.clone(), + self.services.state_db.clone(), ); } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index ac6f766756..2444ded83f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -726,6 +726,7 @@ impl Session { session_configuration.cwd.clone(), &mut default_shell, session_telemetry.clone(), + state_db_ctx.clone(), ) } } else { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b8554b25e9..fe38cd7f64 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3492,6 +3492,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { /*analytics_events_client*/ None, Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), ) @@ -3639,6 +3640,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { live_thread: None, thread_store: Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + /*state_db*/ None, )), model_client: ModelClient::new( Some(auth_manager.clone()), @@ -3810,6 +3812,7 @@ async fn make_session_with_config_and_rx( /*analytics_events_client*/ None, Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), ) @@ -4998,10 +5001,44 @@ async fn make_session_and_context_with_auth_and_config_and_rx( where F: FnOnce(&mut Config), { - let (tx_event, rx_event) = async_channel::unbounded(); let codex_home = tempfile::tempdir().expect("create temp dir"); - let mut config = build_test_config(codex_home.path()).await; + make_session_and_context_with_auth_config_home_and_rx( + auth, + dynamic_tools, + codex_home.path(), + configure_config, + ) + .await +} + +async fn make_session_and_context_with_auth_config_home_and_rx( + auth: CodexAuth, + dynamic_tools: Vec, + codex_home: &Path, + configure_config: F, +) -> ( + Arc, + Arc, + async_channel::Receiver, +) +where + F: FnOnce(&mut Config), +{ + let (tx_event, rx_event) = async_channel::unbounded(); + let mut config = build_test_config(codex_home).await; configure_config(&mut config); + let state_db = if config.features.enabled(Feature::Goals) { + Some( + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("goal tests should initialize sqlite state db"), + ) + } else { + None + }; let config = Arc::new(config); let conversation_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); @@ -5127,10 +5164,11 @@ where agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), - state_db: None, + state_db: state_db.clone(), live_thread: None, thread_store: Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + state_db, )), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -5225,10 +5263,13 @@ async fn make_goal_session_and_context_with_rx() -> ( Arc, Arc, async_channel::Receiver, + tempfile::TempDir, ) { - let (session, turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx( + let codex_home = tempfile::tempdir().expect("create temp dir"); + let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), + codex_home.path(), |config| { config .features @@ -5238,14 +5279,14 @@ async fn make_goal_session_and_context_with_rx() -> ( ) .await; upsert_goal_test_thread(session.as_ref()).await; - (session, turn_context, rx) + (session, turn_context, rx, codex_home) } async fn upsert_goal_test_thread(session: &Session) { let config = session.get_config().await; - let state_db = goal_test_state_db(session) - .await - .expect("goal test state db should initialize"); + let state_db = session + .state_db() + .expect("goal test session should have a state db"); let mut builder = codex_state::ThreadMetadataBuilder::new( session.conversation_id, config @@ -7062,7 +7103,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { #[tokio::test] async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> { - let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await; + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), SetGoalRequest { @@ -7316,6 +7357,9 @@ fn post_goal_token_usage() -> TokenUsage { } async fn goal_test_state_db(sess: &Session) -> anyhow::Result { + if let Some(state_db) = sess.state_db() { + return Ok(state_db); + } let config = sess.get_config().await; codex_state::StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) .await @@ -7323,7 +7367,7 @@ async fn goal_test_state_db(sess: &Session) -> anyhow::Result anyhow::Result<()> { - let (sess, tc, rx) = make_goal_session_and_context_with_rx().await; + let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), SetGoalRequest { @@ -7423,7 +7467,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { - let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await; + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; sess.set_thread_goal( tc.as_ref(), SetGoalRequest { @@ -7485,7 +7529,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> { - let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await; + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; sess.spawn_task( Arc::clone(&tc), Vec::new(), @@ -8149,7 +8193,7 @@ async fn sample_rollout( #[tokio::test] async fn create_goal_tool_rejects_existing_goal() { - let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await; + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let handler = GoalHandler; @@ -8211,7 +8255,7 @@ async fn create_goal_tool_rejects_existing_goal() { #[tokio::test] async fn update_goal_tool_rejects_pausing_goal() { - let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await; + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let handler = GoalHandler; @@ -8271,7 +8315,7 @@ async fn update_goal_tool_rejects_pausing_goal() { #[tokio::test] async fn update_goal_tool_marks_goal_complete() { - let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await; + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let handler = GoalHandler; diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index d6a87d466a..ad7dbb1054 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -731,6 +731,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let skills_watcher = Arc::new(SkillsWatcher::noop()); let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(&config), + /*state_db*/ None, )); let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 40cb4a9605..b328a977d7 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; +use crate::StateDbHandle; use crate::rollout::list::find_thread_path_by_id_str; use crate::shell::Shell; use crate::shell::ShellType; @@ -41,6 +42,7 @@ impl ShellSnapshot { session_cwd: AbsolutePathBuf, shell: &mut Shell, session_telemetry: SessionTelemetry, + state_db: Option, ) -> watch::Sender>> { let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None); shell.shell_snapshot = shell_snapshot_rx; @@ -52,6 +54,7 @@ impl ShellSnapshot { shell.clone(), shell_snapshot_tx.clone(), session_telemetry, + state_db, ); shell_snapshot_tx @@ -64,6 +67,7 @@ impl ShellSnapshot { shell: Shell, shell_snapshot_tx: watch::Sender>>, session_telemetry: SessionTelemetry, + state_db: Option, ) { Self::spawn_snapshot_task( codex_home, @@ -72,6 +76,7 @@ impl ShellSnapshot { shell, shell_snapshot_tx, session_telemetry, + state_db, ); } @@ -82,15 +87,21 @@ impl ShellSnapshot { snapshot_shell: Shell, shell_snapshot_tx: watch::Sender>>, session_telemetry: SessionTelemetry, + state_db: Option, ) { let snapshot_span = info_span!("shell_snapshot", thread_id = %session_id); tokio::spawn( async move { let timer = session_telemetry.start_timer("codex.shell_snapshot.duration_ms", &[]); - let snapshot = - ShellSnapshot::try_new(&codex_home, session_id, &session_cwd, &snapshot_shell) - .await - .map(Arc::new); + let snapshot = ShellSnapshot::try_new( + &codex_home, + session_id, + &session_cwd, + &snapshot_shell, + state_db, + ) + .await + .map(Arc::new); let success = snapshot.is_ok(); let success_tag = if success { "true" } else { "false" }; let _ = timer.map(|timer| timer.record(&[("success", success_tag)])); @@ -110,6 +121,7 @@ impl ShellSnapshot { session_id: ThreadId, session_cwd: &AbsolutePathBuf, shell: &Shell, + state_db: Option, ) -> std::result::Result { // File to store the snapshot let extension = match shell.shell_type { @@ -131,7 +143,9 @@ impl ShellSnapshot { let codex_home = codex_home.clone(); let cleanup_session_id = session_id; tokio::spawn(async move { - if let Err(err) = cleanup_stale_snapshots(&codex_home, cleanup_session_id).await { + if let Err(err) = + cleanup_stale_snapshots(&codex_home, cleanup_session_id, state_db).await + { tracing::warn!("Failed to clean up shell snapshots: {err:?}"); } }); @@ -486,6 +500,7 @@ $envVars | ForEach-Object { pub async fn cleanup_stale_snapshots( codex_home: &AbsolutePathBuf, active_session_id: ThreadId, + state_db: Option, ) -> Result<()> { let snapshot_dir = codex_home.join(SNAPSHOT_DIR); @@ -515,7 +530,8 @@ pub async fn cleanup_stale_snapshots( continue; } - let rollout_path = find_thread_path_by_id_str(codex_home, session_id).await?; + let rollout_path = + find_thread_path_by_id_str(codex_home, session_id, state_db.as_deref()).await?; let Some(rollout_path) = rollout_path else { remove_snapshot_file(&path).await; continue; diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index 0f1aea2021..0199347b4e 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -202,6 +202,7 @@ async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { ThreadId::new(), &dir.path().abs(), &shell, + /*state_db*/ None, ) .await .expect("snapshot should be created"); @@ -227,14 +228,24 @@ async fn try_new_uses_distinct_generation_paths() -> Result<()> { shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; - let initial_snapshot = - ShellSnapshot::try_new(&dir.path().abs(), session_id, &dir.path().abs(), &shell) - .await - .expect("initial snapshot should be created"); - let refreshed_snapshot = - ShellSnapshot::try_new(&dir.path().abs(), session_id, &dir.path().abs(), &shell) - .await - .expect("refreshed snapshot should be created"); + let initial_snapshot = ShellSnapshot::try_new( + &dir.path().abs(), + session_id, + &dir.path().abs(), + &shell, + /*state_db*/ None, + ) + .await + .expect("initial snapshot should be created"); + let refreshed_snapshot = ShellSnapshot::try_new( + &dir.path().abs(), + session_id, + &dir.path().abs(), + &shell, + /*state_db*/ None, + ) + .await + .expect("refreshed snapshot should be created"); let initial_path = initial_snapshot.path.clone(); let refreshed_path = refreshed_snapshot.path.clone(); @@ -428,7 +439,7 @@ async fn cleanup_stale_snapshots_removes_orphans_and_keeps_live() -> Result<()> fs::write(&orphan_snapshot, "orphan").await?; fs::write(&invalid_snapshot, "invalid").await?; - cleanup_stale_snapshots(&codex_home, ThreadId::new()).await?; + cleanup_stale_snapshots(&codex_home, ThreadId::new(), /*state_db*/ None).await?; assert_eq!(live_snapshot.exists(), true); assert_eq!(orphan_snapshot.exists(), false); @@ -451,7 +462,7 @@ async fn cleanup_stale_snapshots_removes_stale_rollouts() -> Result<()> { set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?; - cleanup_stale_snapshots(&codex_home, ThreadId::new()).await?; + cleanup_stale_snapshots(&codex_home, ThreadId::new(), /*state_db*/ None).await?; assert_eq!(stale_snapshot.exists(), false); Ok(()) @@ -472,7 +483,7 @@ async fn cleanup_stale_snapshots_skips_active_session() -> Result<()> { set_file_mtime(&rollout_path, SNAPSHOT_RETENTION + Duration::from_secs(60))?; - cleanup_stale_snapshots(&codex_home, active_session).await?; + cleanup_stale_snapshots(&codex_home, active_session, /*state_db*/ None).await?; assert_eq!(active_snapshot.exists(), true); Ok(()) diff --git a/codex-rs/core/src/state_db_bridge.rs b/codex-rs/core/src/state_db_bridge.rs index c588f039d2..78d3cb11f9 100644 --- a/codex-rs/core/src/state_db_bridge.rs +++ b/codex-rs/core/src/state_db_bridge.rs @@ -3,6 +3,6 @@ pub use codex_rollout::state_db::StateDbHandle; use crate::config::Config; -pub async fn get_state_db(config: &Config) -> Option { - rollout_state_db::get_state_db(config).await +pub async fn init_state_db(config: &Config) -> Option { + rollout_state_db::init(config).await } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 8ae4374e7b..29884da4ae 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -138,8 +138,11 @@ pub(crate) async fn record_completed_response_item( .await; } mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await; - let has_memory_citation = - record_stage1_output_usage_and_detect_memory_citation(turn_context, item).await; + let has_memory_citation = record_stage1_output_usage_and_detect_memory_citation( + sess.services.state_db.as_ref(), + item, + ) + .await; if has_memory_citation { sess.record_memory_citation_for_turn(&turn_context.sub_id) .await; @@ -174,7 +177,7 @@ pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context( } async fn record_stage1_output_usage_and_detect_memory_citation( - turn_context: &TurnContext, + state_db_ctx: Option<&state_db::StateDbHandle>, item: &ResponseItem, ) -> bool { let Some(raw_text) = raw_assistant_output_text_from_item(item) else { @@ -190,7 +193,7 @@ async fn record_stage1_output_usage_and_detect_memory_citation( return true; } - if let Some(db) = state_db::get_state_db(turn_context.config.as_ref()).await { + if let Some(db) = state_db_ctx { let _ = db.record_stage1_output_usage(&thread_ids).await; } true diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 6dbcf7a464..48eec66c58 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -73,6 +73,22 @@ pub fn thread_manager_with_models_provider_and_home( ) } +pub fn thread_manager_with_models_provider_home_and_state( + auth: CodexAuth, + provider: ModelProviderInfo, + codex_home: PathBuf, + environment_manager: Arc, + state_db: Option, +) -> ThreadManager { + ThreadManager::with_models_provider_home_and_state_for_tests( + auth, + provider, + codex_home, + environment_manager, + state_db, + ) +} + pub async fn start_thread_with_user_shell_override( thread_manager: &ThreadManager, config: Config, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 927f45279e..d4e36ab3c8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -50,6 +50,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; +use codex_rollout::state_db::StateDbHandle; use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_thread_store::InMemoryThreadStore; use codex_thread_store::LocalThreadStore; @@ -248,6 +249,7 @@ pub(crate) struct ThreadManagerState { thread_store: Arc, session_source: SessionSource, analytics_events_client: Option, + state_db: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, } @@ -263,10 +265,14 @@ pub fn build_models_manager( ) } -pub fn thread_store_from_config(config: &Config) -> Arc { +pub fn thread_store_from_config( + config: &Config, + state_db: Option, +) -> Arc { match &config.experimental_thread_store { ThreadStoreConfig::Local => Arc::new(LocalThreadStore::new( LocalThreadStoreConfig::from_config(config), + state_db, )), ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)), ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id), @@ -281,6 +287,7 @@ impl ThreadManager { environment_manager: Arc, analytics_events_client: Option, thread_store: Arc, + state_db: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -310,6 +317,7 @@ impl ThreadManager { auth_manager, session_source, analytics_events_client, + state_db, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -347,6 +355,22 @@ impl ThreadManager { provider: ModelProviderInfo, codex_home: PathBuf, environment_manager: Arc, + ) -> Self { + Self::with_models_provider_home_and_state_for_tests( + auth, + provider, + codex_home, + environment_manager, + /*state_db*/ None, + ) + } + + pub(crate) fn with_models_provider_home_and_state_for_tests( + auth: CodexAuth, + provider: ModelProviderInfo, + codex_home: PathBuf, + environment_manager: Arc, + state_db: Option, ) -> Self { set_thread_manager_test_mode_for_tests(/*enabled*/ true); let auth_manager = AuthManager::from_auth_for_testing(auth); @@ -369,12 +393,14 @@ impl ThreadManager { let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager)); // This test constructor has no Config input. Tests that need a non-local // process store should construct ThreadManager::new with an explicit store. - let thread_store: Arc = - Arc::new(LocalThreadStore::new(LocalThreadStoreConfig { + let thread_store: Arc = Arc::new(LocalThreadStore::new( + LocalThreadStoreConfig { codex_home: codex_home.clone(), sqlite_home: codex_home.clone(), default_model_provider_id: OPENAI_PROVIDER_ID.to_string(), - })); + }, + state_db.clone(), + )); Self { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), @@ -390,6 +416,7 @@ impl ThreadManager { auth_manager, session_source: SessionSource::Exec, analytics_events_client: None, + state_db, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), }), @@ -837,6 +864,10 @@ impl ThreadManager { } impl ThreadManagerState { + pub(crate) fn state_db(&self) -> Option { + self.state_db.clone() + } + pub(crate) async fn list_thread_ids(&self) -> Vec { self.threads .read() diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 6c53452184..d50a3ae8f9 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::test_config; +use crate::init_state_db; use crate::rollout::RolloutRecorder; use crate::session::session::SessionSettingsUpdate; use crate::session::tests::make_session_and_context; @@ -389,7 +390,8 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, /*state_db*/ None), + /*state_db*/ None, ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -498,7 +500,8 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, /*state_db*/ None), + /*state_db*/ None, ); let source = manager @@ -551,7 +554,8 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, /*state_db*/ None), + /*state_db*/ None, ); let source = manager @@ -614,7 +618,8 @@ async fn new_uses_active_provider_for_model_refresh() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, /*state_db*/ None), + /*state_db*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -819,13 +824,15 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let state_db = init_state_db(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, state_db.clone()), + state_db.clone(), ); let source = manager @@ -921,13 +928,15 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let state_db = init_state_db(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, state_db.clone()), + state_db.clone(), ); let source = manager @@ -1012,13 +1021,15 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let state_db = init_state_db(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, state_db.clone()), + state_db.clone(), ); let source = manager @@ -1148,13 +1159,15 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let state_db = init_state_db(&config).await; let manager = ThreadManager::new( &config, auth_manager.clone(), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, state_db.clone()), + state_db.clone(), ); let source = manager diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 61dc77eb36..88c1698d8f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3,8 +3,10 @@ use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::function_tool::FunctionCallError; +use crate::init_state_db; use crate::session::tests::make_session_and_context; use crate::session_prefix::format_subagent_notification_message; +use crate::thread_manager::thread_store_from_config; use crate::tools::context::ToolOutput; use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2; @@ -3149,13 +3151,22 @@ async fn close_agent_submits_shutdown_and_returns_previous_status() { #[tokio::test] async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtrees_closed() { let (_session, turn) = make_session_and_context().await; - let manager = thread_manager(); let mut config = turn.config.as_ref().clone(); config.agent_max_depth = 3; config .features .enable(Feature::Sqlite) .expect("test config should allow sqlite"); + let state_db = init_state_db(&config).await; + let manager = ThreadManager::new( + &config, + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), + SessionSource::Exec, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + thread_store_from_config(&config, state_db.clone()), + state_db.clone(), + ); let parent = manager .start_thread(config.clone()) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 291a0795ce..14f652f58b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -423,6 +423,7 @@ impl TestCodexBuilder { environment_manager: Arc, ) -> anyhow::Result { let auth = self.auth.clone(); + let state_db = codex_core::init_state_db(&config).await; let thread_manager = if config.model_catalog.is_some() { ThreadManager::new( &config, @@ -430,14 +431,16 @@ impl TestCodexBuilder { SessionSource::Exec, Arc::clone(&environment_manager), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, state_db.clone()), + state_db.clone(), ) } else { - codex_core::test_support::thread_manager_with_models_provider_and_home( + codex_core::test_support::thread_manager_with_models_provider_home_and_state( auth.clone(), config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::clone(&environment_manager), + state_db.clone(), ) }; let thread_manager = Arc::new(thread_manager); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 6829ea932a..afa586a158 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1112,7 +1112,8 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, /*state_db*/ None), + /*state_db*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index f300745129..68121d2e12 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -141,7 +141,8 @@ async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> { let marker_path = temp.path().join(PERSONALITY_MIGRATION_FILENAME); tokio::fs::write(&marker_path, "v1\n").await?; - let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + let status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); assert_eq!( @@ -155,7 +156,8 @@ async fn migration_marker_exists_no_sessions_no_change() -> io::Result<()> { async fn no_marker_no_sessions_no_change() -> io::Result<()> { let temp = TempDir::new()?; - let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + let status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); assert_eq!( @@ -174,7 +176,8 @@ async fn no_marker_sessions_sets_personality() -> io::Result<()> { let temp = TempDir::new()?; write_session_with_user_event(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + let status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert_eq!( @@ -194,7 +197,7 @@ async fn no_marker_sessions_preserves_existing_config_fields() -> io::Result<()> tokio::fs::write(temp.path().join("config.toml"), "model = \"gpt-5.4\"\n").await?; let config_toml = read_config_toml(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); let persisted = read_config_toml(temp.path()).await?; @@ -208,7 +211,8 @@ async fn no_marker_meta_only_rollout_is_treated_as_no_sessions() -> io::Result<( let temp = TempDir::new()?; write_session_with_meta_only(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + let status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions); assert_eq!( @@ -228,7 +232,7 @@ async fn no_marker_explicit_global_personality_skips_migration() -> io::Result<( write_session_with_user_event(temp.path()).await?; let config_toml = parse_config_toml("personality = \"friendly\"\n")?; - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!( status, @@ -258,7 +262,7 @@ personality = "friendly" "#, )?; - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!( status, @@ -281,7 +285,7 @@ async fn marker_short_circuits_invalid_profile_resolution() -> io::Result<()> { tokio::fs::write(temp.path().join(PERSONALITY_MIGRATION_FILENAME), "v1\n").await?; let config_toml = parse_config_toml("profile = \"missing\"\n")?; - let status = maybe_migrate_personality(temp.path(), &config_toml).await?; + let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::SkippedMarker); Ok(()) @@ -292,7 +296,7 @@ async fn invalid_selected_profile_returns_error_and_does_not_write_marker() -> i let temp = TempDir::new()?; let config_toml = parse_config_toml("profile = \"missing\"\n")?; - let err = maybe_migrate_personality(temp.path(), &config_toml) + let err = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None) .await .expect_err("missing profile should fail"); @@ -309,8 +313,10 @@ async fn applied_migration_is_idempotent_on_second_run() -> io::Result<()> { let temp = TempDir::new()?; write_session_with_user_event(temp.path()).await?; - let first_status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; - let second_status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + let first_status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; + let second_status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; assert_eq!(first_status, PersonalityMigrationStatus::Applied); assert_eq!(second_status, PersonalityMigrationStatus::SkippedMarker); @@ -324,7 +330,8 @@ async fn no_marker_archived_sessions_sets_personality() -> io::Result<()> { let temp = TempDir::new()?; write_archived_session_with_user_event(temp.path()).await?; - let status = maybe_migrate_personality(temp.path(), &ConfigToml::default()).await?; + let status = + maybe_migrate_personality(temp.path(), &ConfigToml::default(), /*state_db*/ None).await?; assert_eq!(status, PersonalityMigrationStatus::Applied); assert_eq!( diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 4fee438261..dc506bc474 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -29,6 +29,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text: "hello from debug prompt".to_string(), text_elements: Vec::new(), }], + /*state_db*/ None, ) .await?; diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index eef0d0f5f4..d0b39a9283 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -14,6 +14,7 @@ use codex_core::find_thread_path_by_id_str; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::SessionSource; +use codex_rollout::StateDbHandle; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; use pretty_assertions::assert_eq; @@ -27,7 +28,13 @@ fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id: std::fs::create_dir_all(&sessions).unwrap(); let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); - let mut f = std::fs::File::create(&file).unwrap(); + write_minimal_rollout_with_id_at_path(&file, id); + + file +} + +fn write_minimal_rollout_with_id_at_path(file: &Path, id: Uuid) { + let mut f = std::fs::File::create(file).unwrap(); // Minimal first line: session_meta with the id so content search can find it writeln!( f, @@ -46,8 +53,6 @@ fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id: }) ) .unwrap(); - - file } /// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the @@ -56,7 +61,11 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { write_minimal_rollout_with_id_in_subdir(codex_home, "sessions", id) } -async fn upsert_thread_metadata(codex_home: &Path, thread_id: ThreadId, rollout_path: PathBuf) { +async fn upsert_thread_metadata( + codex_home: &Path, + thread_id: ThreadId, + rollout_path: PathBuf, +) -> StateDbHandle { let runtime = StateRuntime::init(codex_home.to_path_buf(), "test-provider".to_string()) .await .unwrap(); @@ -73,6 +82,7 @@ async fn upsert_thread_metadata(codex_home: &Path, thread_id: ThreadId, rollout_ builder.cwd = codex_home.to_path_buf(); let metadata = builder.build("test-provider"); runtime.upsert_thread(&metadata).await.unwrap(); + runtime } #[tokio::test] @@ -81,9 +91,10 @@ async fn find_locates_rollout_file_by_id() { let id = Uuid::new_v4(); let expected = write_minimal_rollout_with_id(home.path(), id); - let found = find_thread_path_by_id_str(home.path(), &id.to_string()) - .await - .unwrap(); + let found = + find_thread_path_by_id_str(home.path(), &id.to_string(), /*state_db_ctx*/ None) + .await + .unwrap(); assert_eq!(found.unwrap(), expected); } @@ -97,9 +108,10 @@ async fn find_handles_gitignore_covering_codex_home_directory() { let id = Uuid::new_v4(); let expected = write_minimal_rollout_with_id(&codex_home, id); - let found = find_thread_path_by_id_str(&codex_home, &id.to_string()) - .await - .unwrap(); + let found = + find_thread_path_by_id_str(&codex_home, &id.to_string(), /*state_db_ctx*/ None) + .await + .unwrap(); assert_eq!(found, Some(expected)); } @@ -113,11 +125,11 @@ async fn find_prefers_sqlite_path_by_id() { "sessions/2030/12/30/rollout-2030-12-30T00-00-00-{id}.jsonl" )); std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); - std::fs::write(&db_path, "").unwrap(); + write_minimal_rollout_with_id_at_path(&db_path, id); write_minimal_rollout_with_id(home.path(), id); - upsert_thread_metadata(home.path(), thread_id, db_path.clone()).await; + let state_db = upsert_thread_metadata(home.path(), thread_id, db_path.clone()).await; - let found = find_thread_path_by_id_str(home.path(), &id.to_string()) + let found = find_thread_path_by_id_str(home.path(), &id.to_string(), Some(&state_db)) .await .unwrap(); @@ -134,9 +146,9 @@ async fn find_falls_back_to_filesystem_when_sqlite_has_no_match() { let unrelated_path = home .path() .join("sessions/2030/12/30/rollout-2030-12-30T00-00-00-unrelated.jsonl"); - upsert_thread_metadata(home.path(), unrelated_thread_id, unrelated_path).await; + let state_db = upsert_thread_metadata(home.path(), unrelated_thread_id, unrelated_path).await; - let found = find_thread_path_by_id_str(home.path(), &id.to_string()) + let found = find_thread_path_by_id_str(home.path(), &id.to_string(), Some(&state_db)) .await .unwrap(); @@ -150,9 +162,10 @@ async fn find_ignores_granular_gitignore_rules() { let expected = write_minimal_rollout_with_id(home.path(), id); std::fs::write(home.path().join("sessions/.gitignore"), "*.jsonl\n").unwrap(); - let found = find_thread_path_by_id_str(home.path(), &id.to_string()) - .await - .unwrap(); + let found = + find_thread_path_by_id_str(home.path(), &id.to_string(), /*state_db_ctx*/ None) + .await + .unwrap(); assert_eq!(found, Some(expected)); } @@ -197,7 +210,8 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> ), )?; - let found = find_thread_meta_by_name_str(home.path(), thread_name).await?; + let found = + find_thread_meta_by_name_str(home.path(), thread_name, /*state_db_ctx*/ None).await?; let (path, session_meta) = found.expect("expected rollout path to be found"); assert_eq!(session_meta.meta.id, thread_id); @@ -214,9 +228,13 @@ async fn find_archived_locates_rollout_file_by_id() { let id = Uuid::new_v4(); let expected = write_minimal_rollout_with_id_in_subdir(home.path(), "archived_sessions", id); - let found = find_archived_thread_path_by_id_str(home.path(), &id.to_string()) - .await - .unwrap(); + let found = find_archived_thread_path_by_id_str( + home.path(), + &id.to_string(), + /*state_db_ctx*/ None, + ) + .await + .unwrap(); assert_eq!(found, Some(expected)); } diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index a68af6a1e2..add1ee6ea7 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -246,7 +246,8 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { )?, )), /*analytics_events_client*/ None, - thread_store_from_config(&config), + thread_store_from_config(&config, /*state_db*/ None), + /*state_db*/ None, ); let new_thread = thread_manager.start_thread(config.clone()).await?; let cwd = config.cwd.to_path_buf(); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f2f0ed030b..d61346f1d0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -57,6 +57,7 @@ use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; +use codex_core::StateDbHandle; use codex_core::check_execpolicy_for_warnings; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -194,6 +195,7 @@ impl RequestIdSequencer { struct ExecRunArgs { in_process_start_args: InProcessClientStartArgs, + state_db: Option, command: Option, config: Config, dangerously_bypass_approvals_and_sandbox: bool, @@ -503,6 +505,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?; + let state_db = codex_core::init_state_db(&config).await; let in_process_start_args = InProcessClientStartArgs { arg0_paths, config: std::sync::Arc::new(config.clone()), @@ -511,6 +514,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, + state_db: state_db.clone(), environment_manager: std::sync::Arc::new( EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await, ), @@ -525,6 +529,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result }; run_exec_session(ExecRunArgs { in_process_start_args, + state_db, command, config, dangerously_bypass_approvals_and_sandbox, @@ -546,6 +551,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let ExecRunArgs { in_process_start_args, + state_db, command, config, dangerously_bypass_approvals_and_sandbox, @@ -672,7 +678,9 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) = command.as_ref() { - if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { + if let Some(thread_id) = + resolve_resume_thread_id(&client, &config, state_db.as_ref(), args).await? + { let response: ThreadResumeResponse = send_request_with_response( &client, ClientRequest::ThreadResume { @@ -1318,6 +1326,7 @@ fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool { async fn resolve_resume_thread_id( client: &InProcessAppServerClient, config: &Config, + state_db: Option<&StateDbHandle>, args: &crate::cli::ResumeArgs, ) -> anyhow::Result> { let model_providers = resume_lookup_model_providers(config, args); @@ -1365,7 +1374,7 @@ async fn resolve_resume_thread_id( if Uuid::parse_str(session_id).is_ok() { return Ok(Some(session_id.to_string())); } - if let Some(state_db) = codex_core::get_state_db(config).await { + if let Some(state_db) = state_db { let cwd = (!args.all).then_some(config.cwd.as_path()); let resolved = state_db .find_thread_by_exact_title( @@ -1380,7 +1389,8 @@ async fn resolve_resume_thread_id( return Ok(Some(thread.id.to_string())); } if let Some((_, session_meta)) = - find_thread_meta_by_name_str(&config.codex_home, session_id).await? + find_thread_meta_by_name_str(&config.codex_home, session_id, Some(state_db.as_ref())) + .await? && (args.all || cwds_match(config.cwd.as_path(), &session_meta.meta.cwd)) { return Ok(Some(session_meta.meta.id.to_string())); diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index bb54ffcc53..75372c6e7c 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -83,6 +83,7 @@ pub async fn run_main( std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; set_default_client_residency_requirement(config.enforce_residency.value()); + let state_db = codex_core::init_state_db(&config).await; let otel = codex_core::otel_init::build_provider( &config, @@ -144,6 +145,7 @@ pub async fn run_main( arg0_paths, Arc::new(config), environment_manager, + state_db, ) .await; async move { diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 99076650cc..9ce46dfeda 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; +use codex_core::StateDbHandle; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::thread_store_from_config; @@ -53,6 +54,7 @@ impl MessageProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, environment_manager: Arc, + state_db: Option, ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_from_config( @@ -66,7 +68,8 @@ impl MessageProcessor { SessionSource::Mcp, environment_manager, /*analytics_events_client*/ None, - thread_store_from_config(config.as_ref()), + thread_store_from_config(config.as_ref(), state_db.clone()), + state_db.clone(), )); Self { outgoing, diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index bdb7198835..8ba63a713e 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -1239,6 +1239,7 @@ async fn find_thread_path_by_id_str_in_subdir( codex_home: &Path, subdir: &str, id_str: &str, + state_db_ctx: Option<&codex_state::StateRuntime>, ) -> io::Result> { // Validate UUID format early. if Uuid::parse_str(id_str).is_err() { @@ -1253,8 +1254,8 @@ async fn find_thread_path_by_id_str_in_subdir( _ => None, }; let thread_id = ThreadId::from_string(id_str).ok(); - let state_db_ctx = state_db::open_if_present(codex_home, "").await; - if let Some(state_db_ctx) = state_db_ctx.as_deref() + let mut unverified_db_path = None; + if let Some(state_db_ctx) = state_db_ctx && let Some(thread_id) = thread_id && let Some(db_path) = state_db::find_rollout_path_by_id( Some(state_db_ctx), @@ -1265,21 +1266,43 @@ async fn find_thread_path_by_id_str_in_subdir( .await { if tokio::fs::try_exists(&db_path).await.unwrap_or(false) { - return Ok(Some(db_path)); + match read_session_meta_line(&db_path).await { + Ok(meta_line) if meta_line.meta.id == thread_id => { + return Ok(Some(db_path)); + } + Ok(meta_line) => { + tracing::error!( + "state db returned rollout path for thread {id_str} but file belongs to thread {}: {}", + meta_line.meta.id, + db_path.display() + ); + tracing::warn!( + "state db discrepancy during find_thread_path_by_id_str_in_subdir: mismatched_db_path" + ); + } + Err(err) => { + tracing::debug!( + "state db returned rollout path for thread {id_str} that could not be verified: {}: {err}", + db_path.display() + ); + unverified_db_path = Some(db_path); + } + } + } else { + tracing::error!( + "state db returned stale rollout path for thread {id_str}: {}", + db_path.display() + ); + tracing::warn!( + "state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path" + ); } - tracing::error!( - "state db returned stale rollout path for thread {id_str}: {}", - db_path.display() - ); - tracing::warn!( - "state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path" - ); } let mut root = codex_home.to_path_buf(); root.push(subdir); if !root.exists() { - return Ok(None); + return Ok(unverified_db_path); } // This is safe because we know the values are valid. #[allow(clippy::unwrap_used)] @@ -1301,7 +1324,7 @@ async fn find_thread_path_by_id_str_in_subdir( "state db discrepancy during find_thread_path_by_id_str_in_subdir: falling_back" ); state_db::read_repair_rollout_path( - state_db_ctx.as_deref(), + state_db_ctx, thread_id, archived_only, found_path.as_path(), @@ -1309,7 +1332,7 @@ async fn find_thread_path_by_id_str_in_subdir( .await; } - Ok(found) + Ok(found.or(unverified_db_path)) } /// Locate a recorded thread rollout file by its UUID string using the existing @@ -1318,16 +1341,19 @@ async fn find_thread_path_by_id_str_in_subdir( pub async fn find_thread_path_by_id_str( codex_home: &Path, id_str: &str, + state_db_ctx: Option<&codex_state::StateRuntime>, ) -> io::Result> { - find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str).await + find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str, state_db_ctx).await } /// Locate an archived thread rollout file by its UUID string. pub async fn find_archived_thread_path_by_id_str( codex_home: &Path, id_str: &str, + state_db_ctx: Option<&codex_state::StateRuntime>, ) -> io::Result> { - find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str).await + find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str, state_db_ctx) + .await } /// Extract the `YYYY/MM/DD` directory components from a rollout filename. diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index e7a25f0cda..2dd2df3a41 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -136,6 +136,21 @@ pub(crate) async fn backfill_sessions( runtime: &codex_state::StateRuntime, codex_home: &Path, default_provider: &str, +) { + backfill_sessions_with_lease( + runtime, + codex_home, + default_provider, + BACKFILL_LEASE_SECONDS, + ) + .await; +} + +pub(crate) async fn backfill_sessions_with_lease( + runtime: &codex_state::StateRuntime, + codex_home: &Path, + default_provider: &str, + backfill_lease_seconds: i64, ) { let metric_client = codex_otel::global(); let timer = metric_client @@ -154,7 +169,7 @@ pub(crate) async fn backfill_sessions( if backfill_state.status == BackfillStatus::Complete { return; } - let claimed = match runtime.try_claim_backfill(BACKFILL_LEASE_SECONDS).await { + let claimed = match runtime.try_claim_backfill(backfill_lease_seconds).await { Ok(claimed) => claimed, Err(err) => { warn!( diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index ea0c7e3e36..512c223263 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -79,7 +79,6 @@ pub struct RolloutRecorder { tx: Sender, writer_task: Arc, pub(crate) rollout_path: PathBuf, - state_db: Option, event_persistence_mode: EventPersistenceMode, } @@ -230,6 +229,7 @@ impl RolloutRecorder { /// List threads (rollout files) under the provided Codex home directory. #[allow(clippy::too_many_arguments)] pub async fn list_threads( + state_db_ctx: Option, config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, @@ -242,6 +242,7 @@ impl RolloutRecorder { search_term: Option<&str>, ) -> std::io::Result { Self::list_threads_with_db_fallback( + state_db_ctx, config, page_size, cursor, @@ -260,6 +261,7 @@ impl RolloutRecorder { #[allow(clippy::too_many_arguments)] pub async fn list_threads_from_state_db( + state_db_ctx: Option, config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, @@ -272,6 +274,7 @@ impl RolloutRecorder { search_term: Option<&str>, ) -> std::io::Result { Self::list_threads_with_db_fallback( + state_db_ctx, config, page_size, cursor, @@ -291,6 +294,7 @@ impl RolloutRecorder { /// List archived threads (rollout files) under the archived sessions directory. #[allow(clippy::too_many_arguments)] pub async fn list_archived_threads( + state_db_ctx: Option, config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, @@ -303,6 +307,7 @@ impl RolloutRecorder { search_term: Option<&str>, ) -> std::io::Result { Self::list_threads_with_db_fallback( + state_db_ctx, config, page_size, cursor, @@ -321,6 +326,7 @@ impl RolloutRecorder { #[allow(clippy::too_many_arguments)] pub async fn list_archived_threads_from_state_db( + state_db_ctx: Option, config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, @@ -333,6 +339,7 @@ impl RolloutRecorder { search_term: Option<&str>, ) -> std::io::Result { Self::list_threads_with_db_fallback( + state_db_ctx, config, page_size, cursor, @@ -351,6 +358,7 @@ impl RolloutRecorder { #[allow(clippy::too_many_arguments)] async fn list_threads_with_db_fallback( + state_db_ctx: Option, config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, @@ -365,7 +373,6 @@ impl RolloutRecorder { search_term: Option<&str>, ) -> std::io::Result { let codex_home = config.codex_home(); - let state_db_ctx = state_db::get_state_db(config).await; let archived = match archive_filter { ThreadListArchiveFilter::Active => false, ThreadListArchiveFilter::Archived => true, @@ -575,6 +582,7 @@ impl RolloutRecorder { /// Find the newest recorded thread path, optionally filtering to a matching cwd. #[allow(clippy::too_many_arguments)] pub async fn find_latest_thread_path( + state_db_ctx: Option, config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, @@ -585,7 +593,6 @@ impl RolloutRecorder { filter_cwd: Option<&Path>, ) -> std::io::Result> { let codex_home = config.codex_home(); - let state_db_ctx = state_db::get_state_db(config).await; let cwd_filter = filter_cwd.map(Path::to_path_buf); if state_db_ctx.is_some() { let mut db_cursor = cursor.cloned(); @@ -770,7 +777,6 @@ impl RolloutRecorder { tx, writer_task, rollout_path, - state_db: state_db_ctx, event_persistence_mode, }) } @@ -779,10 +785,6 @@ impl RolloutRecorder { self.rollout_path.as_path() } - pub fn state_db(&self) -> Option { - self.state_db.clone() - } - pub async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { let mut filtered = Vec::new(); for item in items { diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 0138db72df..5711c47bad 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; +use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; @@ -11,6 +12,9 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; @@ -65,6 +69,77 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result anyhow::Result<()> { + let home = TempDir::new().expect("temp dir"); + let uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&uuid.to_string())?; + let rollout_path = home.path().join(format!( + "sessions/2026/01/27/rollout-2026-01-27T12-34-56-{uuid}.jsonl" + )); + let parent = rollout_path + .parent() + .expect("rollout path should have parent"); + fs::create_dir_all(parent)?; + + let session_meta_line = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: "2026-01-27T12:34:56Z".to_string(), + cwd: home.path().to_path_buf(), + originator: "test".to_string(), + cli_version: "test".to_string(), + source: SessionSource::Cli, + agent_path: None, + agent_nickname: None, + agent_role: None, + model_provider: None, + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: None, + }; + let lines = [ + RolloutLine { + timestamp: "2026-01-27T12:34:56Z".to_string(), + item: RolloutItem::SessionMeta(session_meta_line), + }, + RolloutLine { + timestamp: "2026-01-27T12:34:57Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello from startup backfill".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + }, + ]; + let jsonl = lines + .iter() + .map(serde_json::to_string) + .collect::, _>>()? + .join("\n"); + fs::write(&rollout_path, format!("{jsonl}\n"))?; + + let runtime = crate::state_db::init(&test_config(home.path())) + .await + .expect("state db should initialize"); + + let metadata = runtime + .get_thread(thread_id) + .await? + .expect("thread should be backfilled before init returns"); + assert_eq!(metadata.rollout_path, rollout_path); + assert_eq!( + runtime.get_backfill_state().await?.status, + codex_state::BackfillStatus::Complete + ); + + Ok(()) +} + #[tokio::test] async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); @@ -526,6 +601,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re let default_provider = config.model_provider_id.clone(); let page1 = RolloutRecorder::list_threads( + /*state_db_ctx*/ None, &config, /*page_size*/ 1, /*cursor*/ None, @@ -543,6 +619,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re let cursor = page1.next_cursor.clone().expect("cursor should be present"); let page2 = RolloutRecorder::list_threads( + /*state_db_ctx*/ None, &config, /*page_size*/ 1, Some(&cursor), @@ -602,6 +679,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul let default_provider = config.model_provider_id.clone(); let page = RolloutRecorder::list_threads( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -666,6 +744,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul let default_provider = config.model_provider_id.clone(); let page = RolloutRecorder::list_threads( + Some(runtime.clone()), &config, /*page_size*/ 1, /*cursor*/ None, @@ -738,6 +817,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result let cwd_filters = [home.path().to_path_buf()]; let state_db_only_page = RolloutRecorder::list_threads_from_state_db( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -753,6 +833,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result assert_eq!(state_db_only_page.items.len(), 0); let repaired_page = RolloutRecorder::list_threads( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -768,6 +849,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result assert_eq!(repaired_page.items.len(), 1); let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -825,6 +907,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i let cwd_filters = [stale_cwd]; let state_db_only_page = RolloutRecorder::list_threads_from_state_db( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -840,6 +923,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i assert_eq!(state_db_only_page.items.len(), 1); let scanned_page = RolloutRecorder::list_threads( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -855,6 +939,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i assert_eq!(scanned_page.items.len(), 0); let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -913,6 +998,7 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std:: .expect("state db upsert should succeed"); let page = RolloutRecorder::list_threads( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -1039,6 +1125,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s .expect("state db upsert should succeed"); let stale_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -1054,6 +1141,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s assert_eq!(stale_state_db_only_page.items.len(), 1); let scanned_page = RolloutRecorder::list_threads( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, @@ -1069,6 +1157,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s assert_eq!(scanned_page.items.len(), 0); let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + Some(runtime.clone()), &config, /*page_size*/ 10, /*cursor*/ None, diff --git a/codex-rs/rollout/src/session_index.rs b/codex-rs/rollout/src/session_index.rs index 039ce27f24..e227515357 100644 --- a/codex-rs/rollout/src/session_index.rs +++ b/codex-rs/rollout/src/session_index.rs @@ -117,6 +117,7 @@ pub async fn find_thread_names_by_ids( pub async fn find_thread_meta_by_name_str( codex_home: &Path, name: &str, + state_db_ctx: Option<&codex_state::StateRuntime>, ) -> std::io::Result> { if name.trim().is_empty() { return Ok(None); @@ -135,8 +136,12 @@ pub async fn find_thread_meta_by_name_str( while let Some(thread_id) = rx.recv().await { // Keep walking until a matching id resolves to a loadable rollout so an unsaved or partial // rename cannot shadow an older persisted session with the same name. - if let Some(path) = - super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await? + if let Some(path) = super::list::find_thread_path_by_id_str( + codex_home, + &thread_id.to_string(), + state_db_ctx, + ) + .await? && let Ok(session_meta) = super::list::read_session_meta_line(&path).await { drop(rx); diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index c6a539fb28..fbef7eb4f9 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -99,7 +99,7 @@ async fn find_thread_meta_by_name_str_skips_newest_entry_without_rollout() -> st ]; write_index(&path, &lines)?; - let found = find_thread_meta_by_name_str(temp.path(), "same").await?; + let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?; assert_eq!( found.map(|(path, session_meta)| (path, session_meta.meta.id)), @@ -136,7 +136,7 @@ async fn find_thread_meta_by_name_str_skips_partial_rollout() -> std::io::Result ]; write_index(&path, &lines)?; - let found = find_thread_meta_by_name_str(temp.path(), "same").await?; + let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?; assert_eq!(found.map(|(path, _)| path), Some(saved_rollout_path)); Ok(()) @@ -174,7 +174,7 @@ async fn find_thread_meta_by_name_str_ignores_historical_name_after_rename() -> ]; write_index(&path, &lines)?; - let found = find_thread_meta_by_name_str(temp.path(), "same").await?; + let found = find_thread_meta_by_name_str(temp.path(), "same", /*state_db_ctx*/ None).await?; assert_eq!(found.map(|(path, _)| path), Some(current_rollout_path)); Ok(()) diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 41b59c9760..d039e16d68 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -17,15 +17,52 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tracing::info; use tracing::warn; /// Core-facing handle to the SQLite-backed state runtime. pub type StateDbHandle = Arc; -/// Initialize the state runtime for thread state persistence and backfill checks. +#[cfg(not(test))] +const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_secs(1); +#[cfg(test)] +const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(not(test))] +const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(test)] +const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(2); + +/// Initialize the state runtime for thread state persistence. +/// +/// This is the process entry point for local state: it opens the SQLite-backed +/// runtime, applies rollout metadata backfills as needed, and returns the +/// initialized handle. pub async fn init(config: &impl RolloutConfigView) -> Option { let config = RolloutConfig::from_view(config); - init_with_roots( + match try_init_with_roots( + config.codex_home, + config.sqlite_home, + config.model_provider_id, + ) + .await + { + Ok(runtime) => Some(runtime), + Err(err) => { + emit_startup_warning(&format!("failed to initialize state runtime: {err}")); + None + } + } +} + +/// Initialize the state runtime and return any initialization error to the caller. +/// +/// Prefer [`init`] unless the caller needs to surface the exact failure after +/// tracing or UI setup has completed. +pub async fn try_init(config: &impl RolloutConfigView) -> anyhow::Result { + let config = RolloutConfig::from_view(config); + try_init_with_roots( config.codex_home, config.sqlite_home, config.model_provider_id, @@ -33,52 +70,128 @@ pub async fn init(config: &impl RolloutConfigView) -> Option { .await } -/// Initialize the state runtime for a local thread store. -pub async fn init_with_roots( +async fn try_init_with_roots( codex_home: PathBuf, sqlite_home: PathBuf, default_model_provider_id: String, -) -> Option { - let runtime = match codex_state::StateRuntime::init( - sqlite_home.clone(), - default_model_provider_id.clone(), +) -> anyhow::Result { + try_init_with_roots_inner( + codex_home, + sqlite_home, + default_model_provider_id, + /*backfill_lease_seconds*/ None, ) .await - { - Ok(runtime) => runtime, - Err(err) => { - warn!( - "failed to initialize state runtime at {}: {err}", - sqlite_home.display() - ); - return None; - } - }; - let backfill_state = match runtime.get_backfill_state().await { - Ok(state) => state, - Err(err) => { - warn!( +} + +#[cfg(test)] +async fn try_init_with_roots_and_backfill_lease( + codex_home: PathBuf, + sqlite_home: PathBuf, + default_model_provider_id: String, + backfill_lease_seconds: i64, +) -> anyhow::Result { + try_init_with_roots_inner( + codex_home, + sqlite_home, + default_model_provider_id, + Some(backfill_lease_seconds), + ) + .await +} + +async fn try_init_with_roots_inner( + codex_home: PathBuf, + sqlite_home: PathBuf, + default_model_provider_id: String, + backfill_lease_seconds: Option, +) -> anyhow::Result { + let runtime = + codex_state::StateRuntime::init(sqlite_home.clone(), default_model_provider_id.clone()) + .await + .map_err(|err| { + anyhow::anyhow!( + "failed to initialize state runtime at {}: {err}", + sqlite_home.display() + ) + })?; + let wait_started = Instant::now(); + let mut reported_wait = false; + loop { + let backfill_state = runtime.get_backfill_state().await.map_err(|err| { + anyhow::anyhow!( "failed to read backfill state at {}: {err}", codex_home.display() - ); - return None; + ) + })?; + if backfill_state.status == codex_state::BackfillStatus::Complete { + return Ok(runtime); } - }; - if backfill_state.status != codex_state::BackfillStatus::Complete { - let runtime_for_backfill = runtime.clone(); - tokio::spawn(async move { + + if let Some(backfill_lease_seconds) = backfill_lease_seconds { + metadata::backfill_sessions_with_lease( + runtime.as_ref(), + codex_home.as_path(), + default_model_provider_id.as_str(), + backfill_lease_seconds, + ) + .await; + } else { metadata::backfill_sessions( - runtime_for_backfill.as_ref(), + runtime.as_ref(), codex_home.as_path(), default_model_provider_id.as_str(), ) .await; - }); + } + let backfill_state = runtime.get_backfill_state().await.map_err(|err| { + anyhow::anyhow!( + "failed to read backfill state at {} after startup backfill: {err}", + codex_home.display() + ) + })?; + if backfill_state.status == codex_state::BackfillStatus::Complete { + return Ok(runtime); + } + if wait_started.elapsed() >= STARTUP_BACKFILL_WAIT_TIMEOUT { + return Err(anyhow::anyhow!( + "timed out waiting for state db backfill at {} after {:?} (status: {})", + codex_home.display(), + STARTUP_BACKFILL_WAIT_TIMEOUT, + backfill_state.status.as_str() + )); + } + + let message = format!( + "state db backfill is {} at {}; waiting up to {:?} before retrying startup initialization", + backfill_state.status.as_str(), + codex_home.display(), + STARTUP_BACKFILL_WAIT_TIMEOUT, + ); + if reported_wait { + info!("{message}"); + } else { + emit_startup_warning(&message); + reported_wait = true; + } + tokio::time::sleep(STARTUP_BACKFILL_POLL_INTERVAL).await; } - Some(runtime) } -/// Get the DB if the feature is enabled and the DB exists. +fn emit_startup_warning(message: &str) { + warn!("{message}"); + if !tracing::dispatcher::has_been_set() { + #[allow(clippy::print_stderr)] + { + eprintln!("{message}"); + } + } +} + +/// Open the DB if it exists and its startup backfill has already completed. +/// +/// Unlike [`init`], this helper does not run rollout backfill. It is for +/// optional local reads from non-owning contexts such as remote app-server mode. pub async fn get_state_db(config: &impl RolloutConfigView) -> Option { let state_path = codex_state::state_db_path(config.sqlite_home()); if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) { @@ -93,21 +206,6 @@ pub async fn get_state_db(config: &impl RolloutConfigView) -> Option Option { - let db_path = codex_state::state_db_path(codex_home); - if !tokio::fs::try_exists(&db_path).await.unwrap_or(false) { - return None; - } - let runtime = - codex_state::StateRuntime::init(codex_home.to_path_buf(), default_provider.to_string()) - .await - .ok()?; - require_backfill_complete(runtime, codex_home).await -} - async fn require_backfill_complete( runtime: StateDbHandle, codex_home: &Path, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index a4e59db9d0..10a9a3da13 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -7,6 +7,7 @@ use chrono::NaiveDateTime; use chrono::Timelike; use chrono::Utc; use pretty_assertions::assert_eq; +use tempfile::TempDir; #[test] fn cursor_to_anchor_normalizes_timestamp_format() { @@ -22,3 +23,64 @@ fn cursor_to_anchor_normalizes_timestamp_format() { assert_eq!(anchor.ts, expected_ts); } + +#[tokio::test] +async fn try_init_waits_for_concurrent_startup_backfill() -> anyhow::Result<()> { + let home = TempDir::new().expect("temp dir"); + let runtime = + codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".to_string()) + .await?; + let claimed = runtime.try_claim_backfill(/*lease_seconds*/ 60).await?; + assert!(claimed); + let runtime_for_completion = runtime.clone(); + let complete_backfill = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + runtime_for_completion + .mark_backfill_complete(/*last_watermark*/ None) + .await + }); + + let initialized = try_init_with_roots_and_backfill_lease( + home.path().to_path_buf(), + home.path().to_path_buf(), + "test-provider".to_string(), + /*backfill_lease_seconds*/ 60, + ) + .await?; + complete_backfill.await??; + assert_eq!( + initialized.get_backfill_state().await?.status, + codex_state::BackfillStatus::Complete + ); + + Ok(()) +} + +#[tokio::test] +async fn try_init_times_out_waiting_for_stuck_startup_backfill() -> anyhow::Result<()> { + let home = TempDir::new().expect("temp dir"); + let runtime = + codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".to_string()) + .await?; + let claimed = runtime.try_claim_backfill(/*lease_seconds*/ 60).await?; + assert!(claimed); + + let result = try_init_with_roots_and_backfill_lease( + home.path().to_path_buf(), + home.path().to_path_buf(), + "test-provider".to_string(), + /*backfill_lease_seconds*/ 60, + ) + .await; + let err = match result { + Ok(_) => panic!("state db init should not wait forever for incomplete backfill"), + Err(err) => err, + }; + assert!( + err.to_string() + .contains("timed out waiting for state db backfill"), + "unexpected error: {err}" + ); + + Ok(()) +} diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index fba8a9827a..b5c2790dae 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -58,7 +58,7 @@ async fn insert_state_db_thread( thread_id: ThreadId, rollout_path: &Path, archived: bool, -) { +) -> crate::state_db::StateDbHandle { let runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string()) .await .expect("state db should initialize"); @@ -87,6 +87,7 @@ async fn insert_state_db_thread( .upsert_thread(&metadata) .await .expect("state db upsert should succeed"); + runtime } // TODO(jif) fix @@ -236,7 +237,7 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() { let stale_db_path = home.join(format!( "sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl" )); - insert_state_db_thread( + let runtime = insert_state_db_thread( home, thread_id, stale_db_path.as_path(), @@ -244,7 +245,52 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() { ) .await; - let found = find_thread_path_by_id_str(home, &uuid.to_string()) + let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref())) + .await + .expect("lookup should succeed"); + assert_eq!(found, Some(fs_rollout_path.clone())); + assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await; +} + +#[tokio::test] +async fn find_thread_path_falls_back_when_db_path_points_to_another_thread() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + let uuid = Uuid::from_u128(304); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let ts = "2025-01-03T13-00-00"; + write_session_file( + home, + ts, + uuid, + /*num_records*/ 1, + Some(SessionSource::Cli), + ) + .unwrap(); + let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl")); + + let other_uuid = Uuid::from_u128(1304); + let other_ts = "2025-01-04T13-00-00"; + write_session_file( + home, + other_ts, + other_uuid, + /*num_records*/ 1, + Some(SessionSource::Cli), + ) + .unwrap(); + let stale_db_path = home.join(format!( + "sessions/2025/01/04/rollout-{other_ts}-{other_uuid}.jsonl" + )); + let runtime = insert_state_db_thread( + home, + thread_id, + stale_db_path.as_path(), + /*archived*/ false, + ) + .await; + + let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref())) .await .expect("lookup should succeed"); assert_eq!(found, Some(fs_rollout_path.clone())); @@ -269,21 +315,44 @@ async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() { let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl")); // Create an empty state DB so lookup takes the DB-first path and then falls back to files. - let _runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string()) + let runtime = codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string()) .await .expect("state db should initialize"); - _runtime + runtime .mark_backfill_complete(/*last_watermark*/ None) .await .expect("backfill should be complete"); - let found = find_thread_path_by_id_str(home, &uuid.to_string()) + let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref())) .await .expect("lookup should succeed"); assert_eq!(found, Some(fs_rollout_path.clone())); assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await; } +#[tokio::test] +async fn find_thread_path_accepts_existing_state_db_path_without_canonical_filename() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + let uuid = Uuid::from_u128(305); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let db_rollout_path = home.join("sessions/2025/01/03/custom-rollout-name.jsonl"); + fs::create_dir_all(db_rollout_path.parent().expect("rollout parent")).unwrap(); + fs::write(&db_rollout_path, "").unwrap(); + let runtime = insert_state_db_thread( + home, + thread_id, + db_rollout_path.as_path(), + /*archived*/ false, + ) + .await; + + let found = find_thread_path_by_id_str(home, &uuid.to_string(), Some(runtime.as_ref())) + .await + .expect("lookup should succeed"); + assert_eq!(found, Some(db_rollout_path)); +} + #[test] fn rollout_date_parts_extracts_directory_components() { let file_name = OsStr::new("rollout-2025-03-01T09-00-00-123.jsonl"); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index cc2262512d..7064d9e4ce 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -54,6 +54,7 @@ use codex_core_api::WebSearchMode; use codex_core_api::arg0_dispatch_or_else; use codex_core_api::built_in_model_providers; use codex_core_api::find_codex_home; +use codex_core_api::init_state_db; use codex_core_api::item_event_to_server_notification; use codex_core_api::set_default_originator; use codex_core_api::thread_store_from_config; @@ -102,6 +103,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { }; let config = new_config(args.model, arg0_paths)?; + let state_db = init_state_db(&config).await; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; @@ -109,7 +111,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { config.codex_self_exe.clone(), config.codex_linux_sandbox_exe.clone(), )?; - let thread_store = thread_store_from_config(&config); + let thread_store = thread_store_from_config(&config, state_db.clone()); let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await); let thread_manager = ThreadManager::new( @@ -119,6 +121,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { environment_manager, /*analytics_events_client*/ None, Arc::clone(&thread_store), + state_db, ); let NewThread { diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index 5df1d5b761..8fb214e98c 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -13,15 +13,19 @@ pub(super) async fn archive_thread( params: ArchiveThreadParams, ) -> ThreadStoreResult<()> { let thread_id = params.thread_id; - let rollout_path = - find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string()) - .await - .map_err(|err| ThreadStoreError::InvalidRequest { - message: format!("failed to locate thread id {thread_id}: {err}"), - })? - .ok_or_else(|| ThreadStoreError::InvalidRequest { - message: format!("no rollout found for thread id {thread_id}"), - })?; + let state_db_ctx = store.state_db().await; + let rollout_path = find_thread_path_by_id_str( + store.config.codex_home.as_path(), + &thread_id.to_string(), + state_db_ctx.as_deref(), + ) + .await + .map_err(|err| ThreadStoreError::InvalidRequest { + message: format!("failed to locate thread id {thread_id}: {err}"), + })? + .ok_or_else(|| ThreadStoreError::InvalidRequest { + message: format!("no rollout found for thread id {thread_id}"), + })?; let canonical_rollout_path = scoped_rollout_path( store.config.codex_home.join(codex_rollout::SESSIONS_SUBDIR), @@ -48,7 +52,7 @@ pub(super) async fn archive_thread( } })?; - if let Some(ctx) = store.state_db().await { + if let Some(ctx) = state_db_ctx { let _ = ctx .mark_archived(thread_id, archived_path.as_path(), Utc::now()) .await; @@ -77,7 +81,7 @@ mod tests { #[tokio::test] async fn archive_thread_moves_rollout_to_archived_collection() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(201); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -123,7 +127,6 @@ mod tests { async fn archive_thread_updates_sqlite_metadata_when_present() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(202); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -134,6 +137,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); runtime .mark_backfill_complete(/*last_watermark*/ None) .await diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 037bd25085..e470ad2be9 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -39,6 +39,7 @@ pub(super) async fn list_threads( SortDirection::Asc => codex_rollout::SortDirection::Asc, SortDirection::Desc => codex_rollout::SortDirection::Desc, }; + let state_db = store.state_db().await; let rollout_config = RolloutConfig { codex_home: store.config.codex_home.clone(), sqlite_home: store.config.sqlite_home.clone(), @@ -47,6 +48,7 @@ pub(super) async fn list_threads( generate_memories: false, }; let page = list_rollout_threads( + state_db, &rollout_config, store.config.default_model_provider_id.as_str(), ¶ms, @@ -106,6 +108,7 @@ pub(super) async fn list_threads( } async fn list_rollout_threads( + state_db: Option, config: &RolloutConfig, default_model_provider_id: &str, params: &ListThreadsParams, @@ -115,6 +118,7 @@ async fn list_rollout_threads( ) -> ThreadStoreResult { let page = if params.use_state_db_only && params.archived { RolloutRecorder::list_archived_threads_from_state_db( + state_db, config, params.page_size, cursor, @@ -129,6 +133,7 @@ async fn list_rollout_threads( .await } else if params.use_state_db_only { RolloutRecorder::list_threads_from_state_db( + state_db, config, params.page_size, cursor, @@ -143,6 +148,7 @@ async fn list_rollout_threads( .await } else if params.archived { RolloutRecorder::list_archived_threads( + state_db, config, params.page_size, cursor, @@ -157,6 +163,7 @@ async fn list_rollout_threads( .await } else { RolloutRecorder::list_threads( + state_db, config, params.page_size, cursor, @@ -196,7 +203,7 @@ mod tests { #[tokio::test] async fn list_threads_uses_default_provider_when_rollout_omits_provider() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); write_session_file_with( home.path(), home.path().join("sessions/2025/01/03"), @@ -231,7 +238,6 @@ mod tests { async fn list_threads_preserves_sqlite_title_search_results() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(103); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = home.path().join("rollout-title-search.jsonl"); @@ -243,6 +249,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); runtime .mark_backfill_complete(/*last_watermark*/ None) .await @@ -296,7 +303,7 @@ mod tests { #[tokio::test] async fn list_threads_selects_active_or_archived_collection() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let active_uuid = Uuid::from_u128(105); let archived_uuid = Uuid::from_u128(106); write_session_file(home.path(), "2025-01-03T12-00-00", active_uuid) @@ -365,7 +372,7 @@ mod tests { async fn list_threads_returns_local_rollout_summary() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config); + let store = LocalThreadStore::new(config, /*state_db*/ None); let uuid = Uuid::from_u128(101); let path = write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); @@ -404,7 +411,7 @@ mod tests { #[tokio::test] async fn list_threads_rejects_invalid_cursor() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let err = store .list_threads(ListThreadsParams { diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 04dd8b2490..058c7b2309 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -19,7 +19,6 @@ use std::collections::hash_map::Entry; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; -use tokio::sync::OnceCell; use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; @@ -42,7 +41,7 @@ use crate::UpdateThreadMetadataParams; pub struct LocalThreadStore { pub(super) config: LocalThreadStoreConfig, live_recorders: Arc>>, - state_db: Arc>, + state_db: Option, } /// Process-scoped configuration for local thread storage. @@ -76,30 +75,18 @@ impl std::fmt::Debug for LocalThreadStore { } impl LocalThreadStore { - /// Create a local store from process-scoped local storage configuration. - pub fn new(config: LocalThreadStoreConfig) -> Self { + /// Create a local store using an already initialized state DB handle. + pub fn new(config: LocalThreadStoreConfig, state_db: Option) -> Self { Self { config, live_recorders: Arc::new(Mutex::new(HashMap::new())), - state_db: Arc::new(OnceCell::new()), + state_db, } } /// Return the state DB handle used by local rollout writers. pub async fn state_db(&self) -> Option { - self.state_db - .get_or_try_init(|| async { - codex_rollout::state_db::init_with_roots( - self.config.codex_home.clone(), - self.config.sqlite_home.clone(), - self.config.default_model_provider_id.clone(), - ) - .await - .ok_or(()) - }) - .await - .ok() - .cloned() + self.state_db.clone() } /// Read a local rollout-backed thread by path. @@ -302,7 +289,7 @@ mod tests { #[tokio::test] async fn live_writer_lifecycle_writes_and_closes() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let thread_id = ThreadId::default(); store @@ -351,7 +338,7 @@ mod tests { #[tokio::test] async fn create_thread_rejects_missing_cwd() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let thread_id = ThreadId::default(); let mut params = create_thread_params(thread_id); params.metadata.cwd = None; @@ -371,7 +358,7 @@ mod tests { #[tokio::test] async fn discard_thread_drops_unmaterialized_live_writer() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let thread_id = ThreadId::default(); store @@ -410,7 +397,7 @@ mod tests { let config = test_config(home.path()); let thread_id = ThreadId::default(); - let first_store = LocalThreadStore::new(config.clone()); + let first_store = LocalThreadStore::new(config.clone(), /*state_db*/ None); first_store .create_thread(create_thread_params(thread_id)) .await @@ -439,7 +426,7 @@ mod tests { .await .expect("shutdown initial writer"); - let resumed_store = LocalThreadStore::new(config); + let resumed_store = LocalThreadStore::new(config, /*state_db*/ None); resumed_store .resume_thread(ResumeThreadParams { thread_id, @@ -470,7 +457,7 @@ mod tests { #[tokio::test] async fn create_thread_rejects_duplicate_live_writer() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let thread_id = ThreadId::default(); store @@ -490,7 +477,7 @@ mod tests { #[tokio::test] async fn resume_thread_rejects_duplicate_live_writer() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let thread_id = ThreadId::default(); store @@ -519,7 +506,7 @@ mod tests { #[tokio::test] async fn resume_thread_rejects_missing_cwd() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = uuid::Uuid::from_u128(407); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = @@ -548,7 +535,7 @@ mod tests { async fn load_history_uses_live_writer_rollout_path() { let home = TempDir::new().expect("temp dir"); let external_home = TempDir::new().expect("external temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = uuid::Uuid::from_u128(404); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_session_file(external_home.path(), "2025-01-04T10-00-00", uuid) @@ -597,7 +584,7 @@ mod tests { async fn read_thread_uses_live_writer_rollout_path_for_external_resume() { let home = TempDir::new().expect("temp dir"); let external_home = TempDir::new().expect("external temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = uuid::Uuid::from_u128(406); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_session_file(external_home.path(), "2025-01-04T11-00-00", uuid) @@ -636,7 +623,7 @@ mod tests { #[tokio::test] async fn load_history_uses_live_writer_rollout_path_for_archived_source() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = uuid::Uuid::from_u128(405); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = write_archived_session_file(home.path(), "2025-01-04T10-30-00", uuid) @@ -704,7 +691,7 @@ mod tests { #[tokio::test] async fn read_thread_by_rollout_path_includes_history() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let thread_id = ThreadId::default(); store diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 8b3d3160db..097277705f 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -10,7 +10,6 @@ use codex_rollout::find_thread_name_by_id; use codex_rollout::find_thread_path_by_id_str; use codex_rollout::read_session_meta_line; use codex_rollout::read_thread_item_from_rollout; -use codex_state::StateRuntime; use codex_state::ThreadMetadata; use super::LocalThreadStore; @@ -172,16 +171,22 @@ async fn resolve_rollout_path( return Ok(Some(path)); } + let state_db_ctx = store.state_db().await; if include_archived { - match find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string()) - .await - .map_err(|err| ThreadStoreError::InvalidRequest { - message: format!("failed to locate thread id {thread_id}: {err}"), - })? { + match find_thread_path_by_id_str( + store.config.codex_home.as_path(), + &thread_id.to_string(), + state_db_ctx.as_deref(), + ) + .await + .map_err(|err| ThreadStoreError::InvalidRequest { + message: format!("failed to locate thread id {thread_id}: {err}"), + })? { Some(path) => Ok(Some(path)), None => find_archived_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), + state_db_ctx.as_deref(), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -189,11 +194,15 @@ async fn resolve_rollout_path( }), } } else { - find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string()) - .await - .map_err(|err| ThreadStoreError::InvalidRequest { - message: format!("failed to locate thread id {thread_id}: {err}"), - }) + find_thread_path_by_id_str( + store.config.codex_home.as_path(), + &thread_id.to_string(), + state_db_ctx.as_deref(), + ) + .await + .map_err(|err| ThreadStoreError::InvalidRequest { + message: format!("failed to locate thread id {thread_id}: {err}"), + }) } } @@ -246,12 +255,7 @@ async fn read_sqlite_metadata( store: &LocalThreadStore, thread_id: codex_protocol::ThreadId, ) -> Option { - let runtime = StateRuntime::init( - store.config.sqlite_home.clone(), - store.config.default_model_provider_id.clone(), - ) - .await - .ok()?; + let runtime = store.state_db().await?; runtime.get_thread(thread_id).await.ok().flatten() } @@ -411,7 +415,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_active_rollout_summary() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(205); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -439,7 +443,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_rollout_path_summary() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(211); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -470,7 +474,6 @@ mod tests { async fn read_thread_by_rollout_path_prefers_sqlite_git_info() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(223); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -481,6 +484,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, active_path.clone(), @@ -518,7 +522,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_archived_rollout_when_requested() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(207); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T12-00-00", uuid) @@ -559,7 +563,7 @@ mod tests { #[tokio::test] async fn read_thread_prefers_active_rollout_over_archived() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(208); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let active_path = @@ -584,7 +588,7 @@ mod tests { #[tokio::test] async fn read_thread_returns_forked_from_id() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(209); let parent_uuid = Uuid::from_u128(210); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); @@ -617,7 +621,6 @@ mod tests { async fn read_thread_applies_sqlite_thread_name() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(212); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = @@ -628,6 +631,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); builder.model_provider = Some(config.default_model_provider_id.clone()); @@ -657,7 +661,13 @@ mod tests { async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let uuid = Uuid::from_u128(224); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let day_dir = home.path().join("sessions/2025/01/03"); @@ -690,12 +700,6 @@ mod tests { }); writeln!(file, "{user_event}").expect("write user event"); - let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.default_model_provider_id.clone(), - ) - .await - .expect("state db should initialize"); let mut builder = ThreadMetadataBuilder::new( thread_id, rollout_path.clone(), @@ -732,7 +736,7 @@ mod tests { #[tokio::test] async fn read_thread_uses_legacy_thread_name_when_sqlite_title_is_missing() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(213); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); @@ -756,7 +760,6 @@ mod tests { async fn read_thread_uses_sqlite_metadata_for_rollout_without_user_preview() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(217); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let day_dir = home.path().join("sessions/2025/01/03"); @@ -784,6 +787,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, rollout_path.clone(), @@ -826,7 +830,6 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(220); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = @@ -838,6 +841,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, stale_path.clone(), @@ -875,7 +879,6 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(221); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = @@ -889,6 +892,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new(thread_id, stale_path, Utc::now(), SessionSource::Cli); builder.model_provider = Some("wrong-sqlite-provider".to_string()); @@ -920,7 +924,7 @@ mod tests { #[tokio::test] async fn read_thread_uses_session_meta_for_rollout_without_user_preview_or_sqlite_metadata() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(218); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let day_dir = home.path().join("sessions/2025/01/03"); @@ -975,7 +979,6 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(214); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = external @@ -987,6 +990,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, rollout_path.clone(), @@ -1033,7 +1037,6 @@ mod tests { let home = TempDir::new().expect("temp dir"); let external = TempDir::new().expect("external temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(216); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let rollout_path = external @@ -1047,6 +1050,7 @@ mod tests { .expect("state db should initialize"); let mut builder = ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); builder.archived_at = Some(Utc::now()); let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.first_user_message = Some("Archived SQLite preview".to_string()); @@ -1089,7 +1093,6 @@ mod tests { async fn read_thread_sqlite_fallback_loads_archived_history() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(219); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T12-00-00", uuid) @@ -1100,6 +1103,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let mut builder = ThreadMetadataBuilder::new( thread_id, archived_path.clone(), @@ -1135,7 +1139,7 @@ mod tests { #[tokio::test] async fn read_thread_fails_without_rollout() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(206); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index 8a3ab2960a..ad41db69ac 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -17,9 +17,11 @@ pub(super) async fn unarchive_thread( params: ArchiveThreadParams, ) -> ThreadStoreResult { let thread_id = params.thread_id; + let state_db_ctx = store.state_db().await; let archived_path = find_archived_thread_path_by_id_str( store.config.codex_home.as_path(), &thread_id.to_string(), + state_db_ctx.as_deref(), ) .await .map_err(|err| ThreadStoreError::InvalidRequest { @@ -71,7 +73,7 @@ pub(super) async fn unarchive_thread( message: format!("failed to update unarchived thread timestamp: {err}"), })?; - if let Some(ctx) = store.state_db().await { + if let Some(ctx) = state_db_ctx { let _ = ctx .mark_unarchived(thread_id, restored_path.as_path()) .await; @@ -116,7 +118,7 @@ mod tests { #[tokio::test] async fn unarchive_thread_restores_rollout_and_returns_updated_thread() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(203); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T13-00-00", uuid) @@ -147,7 +149,6 @@ mod tests { async fn unarchive_thread_updates_sqlite_metadata_when_present() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(204); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T13-00-00", uuid) @@ -158,6 +159,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); runtime .mark_backfill_complete(/*last_watermark*/ None) .await diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index fba0172525..677aa3fdce 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -157,12 +157,16 @@ async fn resolve_rollout_path( return Ok(ResolvedRolloutPath { path, archived }); } - let active_path = - find_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string()) - .await - .map_err(|err| ThreadStoreError::InvalidRequest { - message: format!("failed to locate thread id {thread_id}: {err}"), - })?; + let state_db_ctx = store.state_db().await; + let active_path = find_thread_path_by_id_str( + store.config.codex_home.as_path(), + &thread_id.to_string(), + state_db_ctx.as_deref(), + ) + .await + .map_err(|err| ThreadStoreError::InvalidRequest { + message: format!("failed to locate thread id {thread_id}: {err}"), + })?; if let Some(path) = active_path { return Ok(ResolvedRolloutPath { path, @@ -174,18 +178,22 @@ async fn resolve_rollout_path( message: format!("thread not found: {thread_id}"), }); } - find_archived_thread_path_by_id_str(store.config.codex_home.as_path(), &thread_id.to_string()) - .await - .map_err(|err| ThreadStoreError::InvalidRequest { - message: format!("failed to locate archived thread id {thread_id}: {err}"), - })? - .map(|path| ResolvedRolloutPath { - path, - archived: true, - }) - .ok_or_else(|| ThreadStoreError::InvalidRequest { - message: format!("thread not found: {thread_id}"), - }) + find_archived_thread_path_by_id_str( + store.config.codex_home.as_path(), + &thread_id.to_string(), + state_db_ctx.as_deref(), + ) + .await + .map_err(|err| ThreadStoreError::InvalidRequest { + message: format!("failed to locate archived thread id {thread_id}: {err}"), + })? + .map(|path| ResolvedRolloutPath { + path, + archived: true, + }) + .ok_or_else(|| ThreadStoreError::InvalidRequest { + message: format!("thread not found: {thread_id}"), + }) } fn rollout_path_is_archived(store: &LocalThreadStore, path: &std::path::Path) -> bool { @@ -213,7 +221,7 @@ mod tests { #[tokio::test] async fn update_thread_metadata_sets_name_on_active_rollout_and_indexes_name() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(301); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = @@ -248,7 +256,6 @@ mod tests { async fn update_thread_metadata_sets_memory_mode_on_active_rollout() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(302); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = @@ -259,6 +266,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); let thread = store .update_thread_metadata(UpdateThreadMetadataParams { @@ -288,7 +296,7 @@ mod tests { async fn update_thread_metadata_uses_live_rollout_path_for_external_resume() { let home = TempDir::new().expect("temp dir"); let external_home = TempDir::new().expect("external temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(307); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = write_session_file(external_home.path(), "2025-01-03T14-45-00", uuid) @@ -328,7 +336,7 @@ mod tests { #[tokio::test] async fn update_thread_metadata_rejects_mismatched_session_meta_id() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let filename_uuid = Uuid::from_u128(303); let metadata_uuid = Uuid::from_u128(304); let thread_id = ThreadId::from_string(&filename_uuid.to_string()).expect("valid thread id"); @@ -360,7 +368,7 @@ mod tests { #[tokio::test] async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() { let home = TempDir::new().expect("temp dir"); - let store = LocalThreadStore::new(test_config(home.path())); + let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); let uuid = Uuid::from_u128(305); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let path = @@ -395,7 +403,6 @@ mod tests { async fn update_thread_metadata_keeps_archived_thread_archived_in_sqlite() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(306); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-00-00", uuid) @@ -406,6 +413,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); runtime .mark_backfill_complete(/*last_watermark*/ None) .await @@ -458,7 +466,6 @@ mod tests { async fn update_thread_metadata_keeps_live_archived_thread_archived_in_sqlite() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); - let store = LocalThreadStore::new(config.clone()); let uuid = Uuid::from_u128(308); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-30-00", uuid) @@ -469,6 +476,7 @@ mod tests { ) .await .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); runtime .mark_backfill_complete(/*last_watermark*/ None) .await diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 13140df3d7..2f3599da64 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -144,6 +144,7 @@ use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; #[cfg(target_os = "windows")] use codex_protocol::permissions::FileSystemSandboxKind; +use codex_rollout::StateDbHandle; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -433,6 +434,7 @@ pub(crate) struct App { pub(crate) chat_widget: ChatWidget, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + pub(crate) state_db: Option, pub(crate) active_profile: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, @@ -609,6 +611,7 @@ impl App { should_prompt_windows_sandbox_nux_at_startup: bool, remote_app_server_url: Option, remote_app_server_auth_token: Option, + state_db: Option, environment_manager: Arc, ) -> Result { use tokio_stream::StreamExt; @@ -854,6 +857,7 @@ See the Codex keymap documentation for supported actions and examples." app_event_tx, chat_widget, config, + state_db, active_profile, cli_kv_overrides, harness_overrides, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index dfe7ceb4f9..8686377a5d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -61,6 +61,7 @@ impl App { }, None => crate::AppServerTarget::Embedded, }, + self.state_db.clone(), self.environment_manager.clone(), ) .await diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index e83abcd0fb..05aba144c8 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -638,7 +638,7 @@ impl App { } else { match crate::session_resume::resolve_cwd_for_resume_or_fork( tui, - &self.config, + self.state_db.as_deref(), ¤t_cwd, target_session.thread_id, target_session.path.as_deref(), diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index eade7bf60e..3d3956c657 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -20,6 +20,7 @@ pub(super) async fn make_test_app() -> App { app_event_tx, chat_widget, config, + state_db: None, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 799ac69e37..787afc7a15 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3771,6 +3771,7 @@ async fn make_test_app() -> App { app_event_tx, chat_widget, config, + state_db: None, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -3832,6 +3833,7 @@ async fn make_test_app_with_channels() -> ( app_event_tx, chat_widget, config, + state_db: None, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index acd79989b6..df6f01e8bd 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -916,7 +916,7 @@ impl App { session.cwd = notification.thread.cwd.clone(); let rollout_path = notification.thread.path.clone(); if let Some(model) = - read_session_model(&self.config, thread_id, rollout_path.as_deref()).await + read_session_model(self.state_db.as_deref(), thread_id, rollout_path.as_deref()).await { session.model = model; } else if rollout_path.is_some() { diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 25ee6cd14a..3a898b82a3 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -87,7 +87,7 @@ impl App { session.instruction_source_paths = Vec::new(); session.rollout_path = thread.path.clone(); if let Some(model) = - read_session_model(&self.config, thread_id, thread.path.as_deref()).await + read_session_model(self.state_db.as_deref(), thread_id, thread.path.as_deref()).await { session.model = model; } else if thread.path.is_some() { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 466b7b9e6e..5128d492fb 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,7 +49,8 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_rollout::state_db::get_state_db; +use codex_rollout::StateDbHandle; +use codex_rollout::state_db; use codex_state::log_db; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; @@ -271,6 +272,7 @@ async fn start_embedded_app_server( cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, + state_db: Option, environment_manager: Arc, ) -> color_eyre::Result { start_embedded_app_server_with( @@ -281,6 +283,7 @@ async fn start_embedded_app_server( cloud_requirements, feedback, log_db, + state_db, environment_manager, InProcessAppServerClient::start, ) @@ -398,6 +401,7 @@ async fn start_app_server( cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, + state_db: Option, environment_manager: Arc, ) -> color_eyre::Result { match target { @@ -409,6 +413,7 @@ async fn start_app_server( cloud_requirements, feedback, log_db, + state_db, environment_manager, ) .await @@ -423,6 +428,7 @@ async fn start_app_server( pub(crate) async fn start_app_server_for_picker( config: &Config, target: &AppServerTarget, + state_db: Option, environment_manager: Arc, ) -> color_eyre::Result { let app_server = start_app_server( @@ -434,6 +440,7 @@ pub(crate) async fn start_app_server_for_picker( CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, + state_db, environment_manager, ) .await?; @@ -444,9 +451,11 @@ pub(crate) async fn start_app_server_for_picker( pub(crate) async fn start_embedded_app_server_for_picker( config: &Config, ) -> color_eyre::Result { + let state_db = state_db::init(config).await; start_app_server_for_picker( config, &AppServerTarget::Embedded, + state_db, Arc::new(EnvironmentManager::default_for_tests()), ) .await @@ -461,6 +470,7 @@ async fn start_embedded_app_server_with( cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, + state_db: Option, environment_manager: Arc, start_client: F, ) -> color_eyre::Result @@ -486,6 +496,7 @@ where cloud_requirements, feedback, log_db, + state_db, environment_manager, config_warnings, session_source: serde_json::from_value(serde_json::json!("cli")) @@ -787,15 +798,6 @@ pub async fn run_main( } }; - if let Err(err) = crate::legacy_core::personality_migration::maybe_migrate_personality( - &codex_home, - &config_toml, - ) - .await - { - tracing::warn!(error = %err, "failed to run personality migration"); - } - let chatgpt_base_url = config_toml .chatgpt_base_url .clone() @@ -865,13 +867,53 @@ pub async fn run_main( ..Default::default() }; - let config = load_config_or_exit( + let mut config = load_config_or_exit( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), ) .await; + let state_db = match &app_server_target { + AppServerTarget::Embedded => state_db::init(&config).await, + AppServerTarget::Remote { .. } => state_db::get_state_db(&config).await, + }; + + let effective_toml = config.config_layer_stack.effective_config(); + match effective_toml.try_into() { + Ok(config_toml) => { + match crate::legacy_core::personality_migration::maybe_migrate_personality( + &config.codex_home, + &config_toml, + state_db.clone(), + ) + .await + { + Ok( + crate::legacy_core::personality_migration::PersonalityMigrationStatus::Applied, + ) => { + config = load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await; + } + Ok( + crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedMarker + | crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality + | crate::legacy_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions, + ) => {} + Err(err) => { + tracing::warn!(error = %err, "failed to run personality migration"); + } + } + } + Err(err) => { + tracing::warn!(error = %err, "failed to deserialize config for personality migration"); + } + } + #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { Ok(None) => {} @@ -1003,7 +1045,7 @@ pub async fn run_main( let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); - let log_db = get_state_db(&config).await.map(log_db::start); + let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); @@ -1029,6 +1071,7 @@ pub async fn run_main( cloud_requirements, feedback, log_db, + state_db, remote_url, remote_auth_token, environment_manager, @@ -1050,6 +1093,7 @@ async fn run_ratatui_app( mut cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, + state_db: Option, remote_url: Option, remote_auth_token: Option, environment_manager: Arc, @@ -1109,6 +1153,7 @@ async fn run_ratatui_app( cloud_requirements.clone(), feedback.clone(), log_db.clone(), + state_db.clone(), environment_manager.clone(), ) .await @@ -1360,7 +1405,7 @@ async fn run_ratatui_app( } else { match resolve_cwd_for_resume_or_fork( &mut tui, - &config, + state_db.as_deref(), ¤t_cwd, target_session.thread_id, target_session.path.as_deref(), @@ -1438,6 +1483,7 @@ async fn run_ratatui_app( cloud_requirements.clone(), feedback.clone(), log_db.clone(), + state_db.clone(), environment_manager.clone(), ) .await @@ -1468,6 +1514,7 @@ async fn run_ratatui_app( should_prompt_windows_sandbox_nux_at_startup, remote_url, remote_auth_token, + state_db, environment_manager, ) .await; @@ -1672,6 +1719,7 @@ mod tests { async fn start_test_embedded_app_server( config: Config, ) -> color_eyre::Result { + let state_db = state_db::init(&config).await; start_embedded_app_server( Arg0DispatchPaths::default(), config, @@ -1680,6 +1728,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, + state_db, Arc::new(EnvironmentManager::default_for_tests()), ) .await @@ -2026,6 +2075,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, + /*state_db*/ None, Arc::new(EnvironmentManager::default_for_tests()), |_args| async { Err(std::io::Error::other("boom")) }, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 9ceef56dbc..6f226b5cfe 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1048,6 +1048,7 @@ mod tests { .await, feedback: codex_feedback::CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new( codex_app_server_client::EnvironmentManager::default_for_tests(), ), diff --git a/codex-rs/tui/src/session_resume.rs b/codex-rs/tui/src/session_resume.rs index 169a096d1e..9b47599edc 100644 --- a/codex-rs/tui/src/session_resume.rs +++ b/codex-rs/tui/src/session_resume.rs @@ -12,10 +12,9 @@ use crate::cwd_prompt; use crate::cwd_prompt::CwdPromptAction; use crate::cwd_prompt::CwdPromptOutcome; use crate::cwd_prompt::CwdSelection; -use crate::legacy_core::config::Config; use crate::tui::Tui; use codex_protocol::ThreadId; -use codex_rollout::state_db::get_state_db; +use codex_state::StateRuntime; use codex_utils_path as path_utils; use serde::Deserialize; use serde_json::Value; @@ -66,11 +65,11 @@ pub(crate) async fn resolve_session_thread_id( } pub(crate) async fn read_session_model( - config: &Config, + state_db_ctx: Option<&StateRuntime>, thread_id: ThreadId, path: Option<&Path>, ) -> Option { - if let Some(state_db_ctx) = get_state_db(config).await + if let Some(state_db_ctx) = state_db_ctx && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await && let Some(model) = metadata.model { @@ -86,14 +85,14 @@ pub(crate) async fn read_session_model( pub(crate) async fn resolve_cwd_for_resume_or_fork( tui: &mut Tui, - config: &Config, + state_db_ctx: Option<&StateRuntime>, current_cwd: &Path, thread_id: ThreadId, path: Option<&Path>, action: CwdPromptAction, allow_prompt: bool, ) -> color_eyre::Result { - let Some(history_cwd) = read_session_cwd(config, thread_id, path).await else { + let Some(history_cwd) = read_session_cwd(state_db_ctx, thread_id, path).await else { return Ok(ResolveCwdOutcome::Continue(None)); }; if allow_prompt && cwds_differ(current_cwd, &history_cwd) { @@ -113,11 +112,11 @@ pub(crate) async fn resolve_cwd_for_resume_or_fork( } async fn read_session_cwd( - config: &Config, + state_db_ctx: Option<&StateRuntime>, thread_id: ThreadId, path: Option<&Path>, ) -> Option { - if let Some(state_db_ctx) = get_state_db(config).await + if let Some(state_db_ctx) = state_db_ctx && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await { return Some(metadata.cwd); From c2fed01550c44196a59b59e42bdcdbc546947f61 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 4 May 2026 11:54:20 -0700 Subject: [PATCH 5/7] rollout: store web search and mcp tool calls (#21054) Codex App would like these. --- codex-rs/rollout/src/policy.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 8631034b33..aeb92ade11 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -102,10 +102,12 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::ContextCompacted(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) + | EventMsg::McpToolCallEnd(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnAborted(_) | EventMsg::TurnStarted(_) | EventMsg::TurnComplete(_) + | EventMsg::WebSearchEnd(_) | EventMsg::ImageGenerationEnd(_) => Some(EventPersistenceMode::Limited), EventMsg::ItemCompleted(event) => { // Plan items are derived from streaming tags and are not part of the @@ -117,14 +119,9 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { None } } - EventMsg::McpToolCallEnd(event) if event.mcp_app_resource_uri.is_some() => { - Some(EventPersistenceMode::Limited) - } EventMsg::Error(_) | EventMsg::GuardianAssessment(_) - | EventMsg::WebSearchEnd(_) | EventMsg::ExecCommandEnd(_) - | EventMsg::McpToolCallEnd(_) | EventMsg::ViewImageToolCall(_) | EventMsg::CollabAgentSpawnEnd(_) | EventMsg::CollabAgentInteractionEnd(_) From cc16995cc6cb5b2d2e2767227c036a3ec76fc671 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 4 May 2026 16:11:15 -0300 Subject: [PATCH 6/7] feat(tui): add PR summary statusline items (#20892) ## Why? The Codex App already exposes branch and PR context in its branch-details UI. This brings the same context into the CLI footer as opt-in statusline items, so users can choose the extra signal without making the default footer busier. ## What? Add optional `pull-request-number` and `branch-changes` items to the configurable TUI status line. - `pull-request-number` shows the open PR for the current checkout and renders as a clickable terminal hyperlink when OSC 8 links are supported. - `branch-changes` shows committed additions/deletions against the repository default branch, or `No changes` when the branch has no committed diff. CleanShot 2026-05-03 at 20 44 15 ## Architecture This follows the same client/app-server split as the Codex App: the TUI owns presentation, caching, and optional rendering, while workspace-sensitive `git` and `gh` discovery runs through app-server. The new TUI-local `workspace_command` layer sends bounded, non-interactive `command/exec` requests to the active app-server. That makes the implementation remote-friendly: the TUI does not decide whether commands run in an embedded local workspace or a remote workspace, and it does not bypass app-server sandbox or permission policy. The branch summary logic stays internal to `codex-tui` because this PR only needs TUI statusline behavior. The command boundary is still isolated behind `WorkspaceCommandExecutor`, so the lookup code can be lifted or reused later without changing statusline rendering. ## How? - Add a TUI `WorkspaceCommandExecutor` abstraction backed by app-server `command/exec`. - Add branch summary probes for: - current branch name, - open PR metadata, - committed branch diff stats against the default branch. - Prefer remote-tracking default branch refs for diff stats, avoiding stale or absent local `main` branches. - Resolve PRs with `gh pr view` first, then fall back to commit-associated PR lookup across parent/fork repos. - Add `/statusline` picker entries, preview values, rendering, and OSC 8 clickable PR links. - Keep all probes best-effort so missing `git`, missing `gh`, auth failures, or non-git directories hide optional items instead of surfacing footer errors. ## Validation - `cargo test -p codex-tui branch_summary -- --nocapture` - Snapshot coverage for the `/statusline` preview/setup rendering paths - Hyperlink rendering coverage for clickable PR statusline cells --- codex-rs/tui/src/app.rs | 11 + codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 49 ++ codex-rs/tui/src/bottom_pane/mod.rs | 6 + ..._snapshot_uses_runtime_preview_values.snap | 2 +- .../tui/src/bottom_pane/status_line_setup.rs | 26 + .../tui/src/bottom_pane/status_line_style.rs | 28 +- .../src/bottom_pane/status_surface_preview.rs | 6 + codex-rs/tui/src/branch_summary.rs | 739 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 46 ++ .../tui/src/chatwidget/status_surfaces.rs | 104 ++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 5 + .../tui/src/chatwidget/tests/plan_mode.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 99 +++ codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 21 +- codex-rs/tui/src/onboarding/mod.rs | 1 + codex-rs/tui/src/workspace_command.rs | 200 +++++ 22 files changed, 1351 insertions(+), 10 deletions(-) create mode 100644 codex-rs/tui/src/branch_summary.rs create mode 100644 codex-rs/tui/src/workspace_command.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2f3599da64..66c5dfd184 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -76,6 +76,8 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use crate::workspace_command::AppServerWorkspaceCommandRunner; +use crate::workspace_command::WorkspaceCommandRunner; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; @@ -432,6 +434,7 @@ pub(crate) struct App { pub(crate) session_telemetry: SessionTelemetry, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, + workspace_command_runner: Option, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) state_db: Option, @@ -574,6 +577,7 @@ impl App { config: cfg, frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), + workspace_command_runner: self.workspace_command_runner.clone(), initial_user_message, enhanced_keys_supported: self.enhanced_keys_supported, has_chatgpt_account: self.chat_widget.has_chatgpt_account(), @@ -712,6 +716,9 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let workspace_command_runner: WorkspaceCommandRunner = Arc::new( + AppServerWorkspaceCommandRunner::new(app_server.request_handle()), + ); let runtime_model_provider_base_url = resolve_runtime_model_provider_base_url(&config.model_provider).await; @@ -734,6 +741,7 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), initial_user_message: crate::chatwidget::create_initial_user_message( initial_prompt.clone(), initial_images.clone(), @@ -769,6 +777,7 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), initial_user_message: crate::chatwidget::create_initial_user_message( initial_prompt.clone(), initial_images.clone(), @@ -809,6 +818,7 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), initial_user_message: crate::chatwidget::create_initial_user_message( initial_prompt.clone(), initial_images.clone(), @@ -856,6 +866,7 @@ See the Codex keymap documentation for supported actions and examples." session_telemetry: session_telemetry.clone(), app_event_tx, chat_widget, + workspace_command_runner: Some(workspace_command_runner), config, state_db, active_profile, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8686377a5d..aefab7a1b3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1848,6 +1848,10 @@ impl App { self.chat_widget.set_status_line_branch(cwd, branch); self.refresh_status_line(); } + AppEvent::StatusLineGitSummaryUpdated { cwd, summary } => { + self.chat_widget.set_status_line_git_summary(cwd, summary); + self.refresh_status_line(); + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 3d3956c657..f34e22203f 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -19,6 +19,7 @@ pub(super) async fn make_test_app() -> App { session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, state_db: None, active_profile: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 787afc7a15..949aa581a4 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -424,6 +424,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm config, frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), + workspace_command_runner: None, initial_user_message: create_initial_user_message( Some(initial_prompt.clone()), Vec::new(), @@ -3770,6 +3771,7 @@ async fn make_test_app() -> App { session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, state_db: None, active_profile: None, @@ -3832,6 +3834,7 @@ async fn make_test_app_with_channels() -> ( session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, state_db: None, active_profile: None, @@ -4733,6 +4736,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { config: app.config.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: app.enhanced_keys_supported, has_chatgpt_account: app.chat_widget.has_chatgpt_account(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bc688e32ce..b9e4380003 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -827,6 +827,11 @@ pub(crate) enum AppEvent { cwd: PathBuf, branch: Option, }, + /// Async update of Git summary fields for status line rendering. + StatusLineGitSummaryUpdated { + cwd: PathBuf, + summary: crate::chatwidget::StatusLineGitSummary, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4275c6743c..4433d79cf4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -190,6 +190,7 @@ use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::primary_binding; +use crate::onboarding::mark_underlined_hyperlink; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; @@ -396,6 +397,7 @@ pub(crate) struct ChatComposer { side_conversation_active: bool, is_zellij: bool, status_line_value: Option>, + status_line_hyperlink_url: Option, status_line_enabled: bool, side_conversation_context_label: Option, // Agent label injected into the footer's contextual row when multi-agent mode is active. @@ -580,6 +582,7 @@ impl ChatComposer { Some(codex_terminal_detection::Multiplexer::Zellij {}) ), status_line_value: None, + status_line_hyperlink_url: None, status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, @@ -4037,6 +4040,14 @@ impl ChatComposer { true } + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { + if self.status_line_hyperlink_url == url { + return false; + } + self.status_line_hyperlink_url = url; + true + } + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { if self.status_line_enabled == enabled { return false; @@ -4441,6 +4452,11 @@ impl ChatComposer { if show_right && let Some(line) = &right_line { render_context_right(hint_rect, buf, line); } + if status_line_active + && let Some(url) = self.status_line_hyperlink_url.as_deref() + { + mark_underlined_hyperlink(buf, hint_rect, url); + } } } } @@ -5022,6 +5038,39 @@ mod tests { ); } + #[test] + fn status_line_hyperlink_marks_pr_number_cells() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let url = "https://github.com/openai/codex/pull/20252"; + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from(Span::styled( + "PR #20252", + Style::default().cyan().underlined(), + )))); + composer.set_status_line_hyperlink(Some(url.to_string())); + + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let marked_cells = (area.top()..area.bottom()) + .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y))) + .filter(|&(x, y)| buf[(x, y)].symbol().contains(url)) + .count(); + assert_eq!( + marked_cells, + "PR #20252".chars().filter(|ch| !ch.is_whitespace()).count() + ); + } + #[test] fn esc_exits_empty_shell_mode() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1f2e4b4953..c839ddd4a7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1544,6 +1544,12 @@ impl BottomPane { } } + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + if self.composer.set_status_line_hyperlink(url) { + self.request_redraw(); + } + } + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { if self.composer.set_status_line_enabled(enabled) { self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap index d29d964d81..db86cf8a78 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap @@ -15,7 +15,7 @@ expression: "render_lines(&view, 72)" [x] git-branch Current Git branch (omitted when unavaila… [ ] model-with-reasoning Current model name with reasoning level [ ] project-name Project name (omitted when unavailable) - [ ] run-state Compact session run-state text (Ready, Wo… + [ ] pull-request-number Open pull request number for the current … gpt-5-codex · ~/codex-rs · jif/statusline-preview Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 5dd79f35e3..5d0ba8718e 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -71,6 +71,12 @@ pub(crate) enum StatusLineItem { /// Current git branch name (if in a repository). GitBranch, + /// Open pull request number for the current branch. + PullRequestNumber, + + /// Committed branch diff stats relative to the default branch. + BranchChanges, + /// Compact runtime run-state text. #[strum(to_string = "run-state", serialize = "status")] Status, @@ -127,6 +133,12 @@ impl StatusLineItem { StatusLineItem::CurrentDir => "Current working directory", StatusLineItem::ProjectRoot => "Project name (omitted when unavailable)", StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)", + StatusLineItem::PullRequestNumber => { + "Open pull request number for the current branch (omitted when unavailable)" + } + StatusLineItem::BranchChanges => { + "Committed branch changes against the default branch (omitted when unavailable)" + } StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)", StatusLineItem::ContextRemaining => { "Percentage of context window remaining (omitted when unknown)" @@ -165,6 +177,8 @@ impl StatusLineItem { StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir, StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot, StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch, + StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber, + StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges, StatusLineItem::Status => StatusSurfacePreviewItem::Status, StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining, StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed, @@ -409,6 +423,18 @@ mod tests { ); } + #[test] + fn git_summary_items_are_selectable_ids() { + assert_eq!( + "pull-request-number".parse::(), + Ok(StatusLineItem::PullRequestNumber) + ); + assert_eq!( + "branch-changes".parse::(), + Ok(StatusLineItem::BranchChanges) + ); + } + #[test] fn parse_status_line_items_accepts_title_only_variants() { let items = ["run-state", "task-progress"] diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 1449256a64..dddd02db09 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -32,7 +32,9 @@ impl StatusLineAccent { match item { StatusLineItem::ModelName | StatusLineItem::ModelWithReasoning => Self::Model, StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path, - StatusLineItem::GitBranch => Self::Branch, + StatusLineItem::GitBranch + | StatusLineItem::PullRequestNumber + | StatusLineItem::BranchChanges => Self::Branch, StatusLineItem::Status => Self::State, StatusLineItem::ContextRemaining | StatusLineItem::ContextUsed @@ -106,6 +108,11 @@ where } else { Style::default().dim() }; + let style = if item == StatusLineItem::PullRequestNumber { + style.underlined() + } else { + style + }; spans.push(Span::styled(text, style)); } @@ -256,6 +263,25 @@ mod tests { assert!(line.spans[2].style.add_modifier.contains(Modifier::DIM)); } + #[test] + fn pull_request_number_uses_link_style() { + let line = status_line_from_segments_with_resolver( + [(StatusLineItem::PullRequestNumber, "PR #20252".to_string())], + /*use_theme_colors*/ false, + |_| None, + ) + .expect("status line"); + + assert_eq!(line.spans[0].style.fg, None); + assert!(line.spans[0].style.add_modifier.contains(Modifier::DIM)); + assert!( + line.spans[0] + .style + .add_modifier + .contains(Modifier::UNDERLINED) + ); + } + #[test] fn status_line_segments_return_none_when_empty() { assert_eq!( diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 084ff10566..581d424aaf 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -14,6 +14,8 @@ pub(crate) enum StatusSurfacePreviewItem { Status, ThreadTitle, GitBranch, + PullRequestNumber, + BranchChanges, ContextRemaining, ContextUsed, FiveHourLimit, @@ -40,6 +42,8 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::Status => "Working", StatusSurfacePreviewItem::ThreadTitle => "thread title", StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", + StatusSurfacePreviewItem::PullRequestNumber => "PR #123", + StatusSurfacePreviewItem::BranchChanges => "+12 -3", StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", StatusSurfacePreviewItem::ContextUsed => "Context 0% used", StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", @@ -66,6 +70,8 @@ impl StatusSurfacePreviewItem { Self::Status, Self::ThreadTitle, Self::GitBranch, + Self::PullRequestNumber, + Self::BranchChanges, Self::ContextRemaining, Self::ContextUsed, Self::FiveHourLimit, diff --git a/codex-rs/tui/src/branch_summary.rs b/codex-rs/tui/src/branch_summary.rs new file mode 100644 index 0000000000..4698dc96e5 --- /dev/null +++ b/codex-rs/tui/src/branch_summary.rs @@ -0,0 +1,739 @@ +//! Branch and pull-request metadata for TUI status-line items. +//! +//! This module owns the git and GitHub probes behind the TUI `git-branch`, `pull-request-number`, +//! and `branch-changes` status-line items. It deliberately talks only to a +//! `WorkspaceCommandExecutor`, not to `tokio::process::Command`, so the same lookup logic works +//! when the TUI is connected to either an embedded or remote app-server. +//! +//! All lookups are best-effort. A failed command, missing `git` or `gh`, unauthenticated GitHub +//! CLI, non-git directory, or ambiguous repository state should result in absent optional metadata +//! rather than a user-visible error. The status line can then render whichever pieces are available +//! without blocking the rest of the UI. + +#[cfg(test)] +use std::collections::VecDeque; +use std::path::Path; + +use serde::Deserialize; + +use crate::workspace_command::WorkspaceCommand; +#[cfg(test)] +use crate::workspace_command::WorkspaceCommandError; +use crate::workspace_command::WorkspaceCommandExecutor; +use crate::workspace_command::WorkspaceCommandOutput; + +/// Additions and deletions between `HEAD` and a branch comparison base. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GitBranchDiffStats { + /// Total added lines in committed changes on the current branch. + pub(crate) additions: u64, + /// Total deleted lines in committed changes on the current branch. + pub(crate) deletions: u64, +} + +/// Combined git metadata cached by the status line for one working directory. +/// +/// A summary may contain only one of the fields when the other probe fails. Renderers should treat +/// missing fields as omitted optional UI rather than as a hard lookup failure. +#[derive(Clone, Debug, Default)] +pub(crate) struct StatusLineGitSummary { + /// Open pull request associated with the current branch or HEAD commit. + pub(crate) pull_request: Option, + /// Additions and deletions between `HEAD` and the repository default branch merge base. + pub(crate) branch_change_stats: Option, +} + +/// Open GitHub pull request shown by the `pull-request-number` status-line item. +/// +/// The URL is kept with the number so clickable renderers can open the same PR represented by the +/// compact label. Callers should only construct this for open PRs; closed or merged PRs are filtered +/// out by this module. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StatusLinePullRequest { + /// GitHub pull request number. + pub(crate) number: u64, + /// Browser URL for the pull request. + pub(crate) url: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DefaultBranch { + /// Git ref used for merge-base comparison. + /// + /// This may be a remote-tracking ref such as `refs/remotes/origin/main`, which avoids + /// comparing against a stale or absent local `main` branch. + merge_ref: String, +} + +#[derive(Deserialize)] +struct GhPullRequestView { + number: u64, + url: String, + state: String, +} + +#[derive(Deserialize)] +struct GhPullRequestApiItem { + number: u64, + #[serde(rename = "html_url")] + url: String, + state: String, +} + +#[derive(Deserialize)] +struct GhRepoView { + #[serde(rename = "nameWithOwner")] + name_with_owner: Option, + parent: Option, +} + +#[derive(Deserialize)] +struct GhRepoParent { + #[serde(rename = "nameWithOwner")] + name_with_owner: String, +} + +/// Returns the checked-out branch name for one status-line working directory. +/// +/// Detached HEADs, non-git directories, and command failures return `None` so the renderer can +/// omit the branch item without surfacing a background lookup error. +pub(crate) async fn current_branch_name( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let output = run_git_command(runner, cwd, &["branch", "--show-current"]) + .await + .ok()?; + if !output.success() { + return None; + } + + Some(output.stdout.trim().to_string()).filter(|name| !name.is_empty()) +} + +/// Resolves PR and branch-change metadata for one status-line working directory. +/// +/// The PR and diff-stat probes run concurrently because each is independent and both are optional. +/// The returned summary is suitable for caching by `cwd`; callers should discard it if the active +/// status-line cwd changes before the async lookup completes. +pub(crate) async fn status_line_git_summary( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> StatusLineGitSummary { + let (pull_request, branch_change_stats) = tokio::join!( + open_pull_request(runner, cwd), + branch_diff_stats_to_default_branch(runner, cwd), + ); + StatusLineGitSummary { + pull_request, + branch_change_stats, + } +} + +/// Counts committed line changes between `HEAD` and the repository default branch. +/// +/// The comparison base is the merge base with a verified default-branch ref. Uncommitted working +/// tree edits are intentionally ignored because the status-line item summarizes the checked-out +/// branch, not the current dirty worktree. +async fn branch_diff_stats_to_default_branch( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let git_dir = run_git_command(runner, cwd, &["rev-parse", "--git-dir"]) + .await + .ok()?; + if !git_dir.success() { + return None; + } + + let default_branch = get_default_branch(runner, cwd).await?; + let merge_base = run_git_command( + runner, + cwd, + &["merge-base", "HEAD", &default_branch.merge_ref], + ) + .await + .ok()?; + if !merge_base.success() { + return None; + } + let merge_base = merge_base.stdout.trim(); + if merge_base.is_empty() { + return None; + } + + let range = format!("{merge_base}..HEAD"); + let numstat = run_git_command(runner, cwd, &["diff", "--numstat", &range]) + .await + .ok()?; + if !numstat.success() { + return None; + } + + let mut additions = 0_u64; + let mut deletions = 0_u64; + for line in numstat.stdout.lines() { + let mut columns = line.split('\t'); + additions += columns + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + deletions += columns + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + } + + Some(GitBranchDiffStats { + additions, + deletions, + }) +} + +/// Returns git remotes in the order used for default-branch discovery. +/// +/// `origin` is prioritized because most repositories use it as the canonical upstream. Other +/// remotes are still tried so fork or enterprise layouts with a differently named upstream can +/// produce branch-change stats when their remote HEAD is configured. +async fn get_git_remotes(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option> { + let output = run_git_command(runner, cwd, &["remote"]).await.ok()?; + if !output.success() { + return None; + } + + let mut remotes: Vec = output.stdout.lines().map(str::to_string).collect(); + if let Some(pos) = remotes.iter().position(|remote| remote == "origin") { + let origin = remotes.remove(pos); + remotes.insert(0, origin); + } + Some(remotes) +} + +/// Resolves the default branch ref that should be used for branch-change comparisons. +/// +/// The lookup prefers remote-tracking refs over local branches so feature-only clones and stale +/// local `main` branches do not inflate the status-line diff. When no remote default is available, +/// local `main` or `master` is used as a last resort. +async fn get_default_branch( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let remotes = get_git_remotes(runner, cwd).await.unwrap_or_default(); + for remote in remotes { + if let Some(branch) = + get_remote_default_branch_from_symbolic_ref(runner, cwd, &remote).await + { + return Some(branch); + } + + if let Some(branch) = get_remote_default_branch_from_remote_show(runner, cwd, &remote).await + { + return Some(branch); + } + } + + get_default_branch_local(runner, cwd).await +} + +/// Resolves a remote's symbolic HEAD into a concrete remote-tracking ref. +/// +/// The returned ref is verified before use. Without that check, a symbolic `origin/HEAD` left over +/// from an old fetch could point at a ref that no longer exists, causing the later merge-base probe +/// to fail in a less obvious place. +async fn get_remote_default_branch_from_symbolic_ref( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + remote: &str, +) -> Option { + let remote_head = format!("refs/remotes/{remote}/HEAD"); + let output = run_git_command(runner, cwd, &["symbolic-ref", "--quiet", &remote_head]) + .await + .ok()?; + if !output.success() { + return None; + } + + let trimmed = output.stdout.trim(); + let remote_ref_prefix = format!("refs/remotes/{remote}/"); + trimmed.strip_prefix(&remote_ref_prefix)?; + if !git_ref_exists(runner, cwd, trimmed).await { + return None; + } + + Some(DefaultBranch { + merge_ref: trimmed.to_string(), + }) +} + +/// Parses `git remote show` output to discover a remote's default branch ref. +/// +/// This is a fallback for repositories where `refs/remotes//HEAD` is not configured but +/// `git remote show` can still report the upstream HEAD branch. The concrete remote-tracking ref +/// must already exist locally before it is accepted. +async fn get_remote_default_branch_from_remote_show( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + remote: &str, +) -> Option { + let output = run_git_command(runner, cwd, &["remote", "show", remote]) + .await + .ok()?; + if !output.success() { + return None; + } + + for line in output.stdout.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("HEAD branch:") else { + continue; + }; + let name = rest.trim(); + let remote_ref = format!("refs/remotes/{remote}/{name}"); + if !name.is_empty() && git_ref_exists(runner, cwd, &remote_ref).await { + return Some(DefaultBranch { + merge_ref: remote_ref, + }); + } + } + + None +} + +/// Falls back to local `main` or `master` when no remote default branch can be found. +async fn get_default_branch_local( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + for candidate in ["main", "master"] { + let local_ref = format!("refs/heads/{candidate}"); + if git_ref_exists(runner, cwd, &local_ref).await { + return Some(DefaultBranch { + merge_ref: local_ref, + }); + } + } + + None +} + +/// Checks whether a git ref exists in the status-line working directory. +async fn git_ref_exists( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + reference: &str, +) -> bool { + run_git_command( + runner, + cwd, + &["rev-parse", "--verify", "--quiet", reference], + ) + .await + .is_ok_and(|output| output.success()) +} + +/// Resolves the open PR associated with the current checkout. +/// +/// Branch-based lookup is attempted first because it is cheap and mirrors `gh pr view`. Commit-based +/// lookup is used as a fallback so fork workflows can still find a PR opened against the upstream +/// repository even when `gh` infers the fork from the current checkout. +async fn open_pull_request( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + if let Some(pull_request) = open_pull_request_for_current_branch(runner, cwd).await { + return Some(pull_request); + } + + open_pull_request_for_head_commit(runner, cwd).await +} + +/// Uses GitHub CLI's current-branch PR lookup. +async fn open_pull_request_for_current_branch( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let output = run_gh_command(runner, cwd, &["pr", "view", "--json", "number,url,state"]) + .await + .ok()?; + if !output.success() { + return None; + } + pull_request_from_view_output(&output.stdout) +} + +/// Looks up open PRs for `HEAD` across the upstream/fork repository search order. +async fn open_pull_request_for_head_commit( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let head_sha = current_head_sha(runner, cwd).await?; + for repo in gh_repo_search_order(runner, cwd).await? { + let endpoint = format!("repos/{repo}/commits/{head_sha}/pulls"); + let output = run_gh_command( + runner, + cwd, + &[ + "api", + "-H", + "Accept: application/vnd.github+json", + &endpoint, + ], + ) + .await + .ok()?; + if output.success() + && let Some(pull_request) = pull_request_from_api_output(&output.stdout) + { + return Some(pull_request); + } + } + + None +} + +/// Returns the current `HEAD` SHA for commit-based PR lookup. +async fn current_head_sha(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option { + let output = run_git_command(runner, cwd, &["rev-parse", "HEAD"]) + .await + .ok()?; + if !output.success() { + return None; + } + + Some(output.stdout.trim().to_string()).filter(|sha| !sha.is_empty()) +} + +/// Returns repositories to query for commit-associated PRs, with parent before fork. +async fn gh_repo_search_order( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option> { + let output = run_gh_command( + runner, + cwd, + &["repo", "view", "--json", "nameWithOwner,parent"], + ) + .await + .ok()?; + if !output.success() { + return None; + } + + repo_search_order_from_output(&output.stdout) +} + +/// Parses `gh pr view --json number,url,state` output for an open PR. +fn pull_request_from_view_output(stdout: &str) -> Option { + let pull_request = serde_json::from_str::(stdout).ok()?; + pull_request + .state + .eq_ignore_ascii_case("open") + .then_some(StatusLinePullRequest { + number: pull_request.number, + url: pull_request.url, + }) +} + +/// Parses the GitHub REST commit-to-PR response and returns the first open PR. +fn pull_request_from_api_output(stdout: &str) -> Option { + serde_json::from_str::>(stdout) + .ok()? + .into_iter() + .find(|pull_request| pull_request.state.eq_ignore_ascii_case("open")) + .map(|pull_request| StatusLinePullRequest { + number: pull_request.number, + url: pull_request.url, + }) +} + +/// Parses `gh repo view` output into the repository search order for fallback PR lookup. +/// +/// Parent-first ordering matches upstream PR workflows: a branch may be checked out from a fork +/// while the open PR lives on the parent repository. +fn repo_search_order_from_output(stdout: &str) -> Option> { + let repo = serde_json::from_str::(stdout).ok()?; + let mut repos = Vec::new(); + if let Some(parent) = repo.parent { + repos.push(parent.name_with_owner); + } + if let Some(name_with_owner) = repo.name_with_owner + && !repos.iter().any(|repo| repo == &name_with_owner) + { + repos.push(name_with_owner); + } + if repos.is_empty() { + return None; + } + + Some(repos) +} + +/// Runs a git command through the workspace-command abstraction. +async fn run_git_command( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("git".to_string()); + argv.extend(args.iter().map(|arg| (*arg).to_string())); + runner + .run( + WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .env("GIT_OPTIONAL_LOCKS", "0"), + ) + .await +} + +/// Runs a GitHub CLI command through the workspace-command abstraction. +/// +/// Prompting is disabled because status-line probes are background UI work. A command that needs +/// authentication or user input should fail and leave the optional PR item hidden. +async fn run_gh_command( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("gh".to_string()); + argv.extend(args.iter().map(|arg| (*arg).to_string())); + runner + .run( + WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .env("GH_PROMPT_DISABLED", "1") + .env("GIT_TERMINAL_PROMPT", "0"), + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace_command::WorkspaceCommand; + use pretty_assertions::assert_eq; + use std::future::Future; + use std::pin::Pin; + use std::sync::Mutex; + + #[tokio::test] + async fn branch_diff_stats_prefers_remote_default_ref_over_stale_local_branch() { + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--git-dir"], + /*exit_code*/ 0, + ".git\n", + ), + response(&["git", "remote"], /*exit_code*/ 0, "origin\n"), + response( + &["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + /*exit_code*/ 0, + "refs/remotes/origin/main\n", + ), + response( + &[ + "git", + "rev-parse", + "--verify", + "--quiet", + "refs/remotes/origin/main", + ], + /*exit_code*/ 0, + "remote-main-sha\n", + ), + response( + &["git", "merge-base", "HEAD", "refs/remotes/origin/main"], + /*exit_code*/ 0, + "base-sha\n", + ), + response( + &["git", "diff", "--numstat", "base-sha..HEAD"], + /*exit_code*/ 0, + "1\t0\tfile\n", + ), + ]); + + let stats = branch_diff_stats_to_default_branch(&runner, Path::new("/repo")) + .await + .expect("branch diff stats"); + + assert_eq!( + stats, + GitBranchDiffStats { + additions: 1, + deletions: 0, + } + ); + assert!(runner.saw(&["git", "merge-base", "HEAD", "refs/remotes/origin/main"])); + } + + #[tokio::test] + async fn open_pull_request_uses_current_branch_view_first() { + let runner = FakeRunner::new(vec![response( + &["gh", "pr", "view", "--json", "number,url,state"], + /*exit_code*/ 0, + r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"#, + )]); + + let pull_request = open_pull_request(&runner, Path::new("/repo")) + .await + .expect("pull request"); + + assert_eq!( + pull_request, + StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + } + ); + assert!(!runner.saw(&["git", "rev-parse", "HEAD"])); + } + + #[tokio::test] + async fn open_pull_request_falls_back_to_parent_repo_commit_lookup() { + let runner = FakeRunner::new(vec![ + response( + &["gh", "pr", "view", "--json", "number,url,state"], + /*exit_code*/ 1, + "", + ), + response( + &["git", "rev-parse", "HEAD"], + /*exit_code*/ 0, + "head-sha\n", + ), + response( + &["gh", "repo", "view", "--json", "nameWithOwner,parent"], + /*exit_code*/ 0, + r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"#, + ), + response( + &[ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + "repos/openai/codex/commits/head-sha/pulls", + ], + /*exit_code*/ 0, + r#"[{"number":20252,"html_url":"https://github.com/openai/codex/pull/20252","state":"open"}]"#, + ), + ]); + + let pull_request = open_pull_request(&runner, Path::new("/repo")) + .await + .expect("pull request"); + + assert_eq!( + pull_request, + StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + } + ); + assert!(runner.saw(&[ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + "repos/openai/codex/commits/head-sha/pulls", + ])); + } + + #[test] + fn status_line_pr_view_parser_requires_open_pr() { + assert_eq!( + pull_request_from_view_output( + r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"# + ), + Some(StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + }) + ); + + assert_eq!( + pull_request_from_view_output( + r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"MERGED"}"# + ), + None + ); + } + + #[test] + fn status_line_pr_fallback_searches_parent_repo_first() { + assert_eq!( + repo_search_order_from_output( + r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"# + ), + Some(vec!["openai/codex".to_string(), "fcoury/codex".to_string()]) + ); + } + + fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse { + FakeResponse { + argv: argv.iter().map(|arg| (*arg).to_string()).collect(), + output: WorkspaceCommandOutput { + exit_code, + stdout: stdout.to_string(), + stderr: String::new(), + }, + } + } + + struct FakeResponse { + argv: Vec, + output: WorkspaceCommandOutput, + } + + struct FakeRunner { + responses: Mutex>, + seen: Mutex>>, + } + + impl FakeRunner { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + seen: Mutex::new(Vec::new()), + } + } + + fn saw(&self, argv: &[&str]) -> bool { + let argv: Vec = argv.iter().map(|arg| (*arg).to_string()).collect(); + self.seen + .lock() + .expect("seen lock") + .iter() + .any(|seen| seen == &argv) + } + } + + impl WorkspaceCommandExecutor for FakeRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + self.seen + .lock() + .expect("seen lock") + .push(command.argv.clone()); + Box::pin(async move { + let mut responses = self.responses.lock().expect("responses lock"); + let index = responses + .iter() + .position(|response| response.argv == command.argv) + .unwrap_or_else(|| panic!("missing fake response for {:?}", command.argv)); + let response = responses.remove(index).expect("fake response"); + Ok(response.output) + }) + } + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d06c674566..6d15d64aab 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -349,11 +349,13 @@ use self::status_surfaces::TerminalTitleStatusKind; mod user_messages; use self::user_messages::PendingSteerCompareKey; use self::user_messages::UserMessageDisplay; +pub(crate) use crate::branch_summary::StatusLineGitSummary; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; +use crate::workspace_command::WorkspaceCommandRunner; use chrono::Local; use codex_app_server_protocol::AskForApproval; @@ -554,6 +556,11 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, + /// App-server-backed runner used by status surfaces for workspace metadata probes. + /// + /// Tests that do not exercise git status-line refreshes may leave this unset. Production TUI + /// construction provides a runner for the active app-server session. + pub(crate) workspace_command_runner: Option, pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) has_chatgpt_account: bool, @@ -972,6 +979,8 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, // Current working directory (if known) current_cwd: Option, + // App-server-backed command runner for status-line workspace metadata lookups. + workspace_command_runner: Option, // Instruction source files loaded for the current session, supplied by app-server. instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. @@ -1004,6 +1013,14 @@ pub(crate) struct ChatWidget { status_line_branch_pending: bool, // True once we've attempted a branch lookup for the current CWD. status_line_branch_lookup_complete: bool, + // Cached PR and branch-change summary for the active status-line cwd. + status_line_git_summary: Option, + // CWD used to resolve the cached Git summary; change resets summary state. + status_line_git_summary_cwd: Option, + // True while an async Git summary lookup is in flight. + status_line_git_summary_pending: bool, + // True once we've attempted a Git summary lookup for the current CWD. + status_line_git_summary_lookup_complete: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1831,6 +1848,11 @@ impl ChatWidget { self.bottom_pane.set_status_line(status_line); } + /// Sets the terminal hyperlink target for the currently rendered footer status line. + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + self.bottom_pane.set_status_line_hyperlink(url); + } + /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. /// /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the @@ -1929,6 +1951,22 @@ impl ChatWidget { self.refresh_status_surfaces(); } + /// Stores async Git summary lookup results for the current status-line cwd. + pub(crate) fn set_status_line_git_summary( + &mut self, + cwd: PathBuf, + summary: StatusLineGitSummary, + ) { + if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { + self.status_line_git_summary_pending = false; + return; + } + self.status_line_git_summary = Some(summary); + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = true; + self.refresh_status_surfaces(); + } + fn collect_runtime_metrics_delta(&mut self) { if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); @@ -2499,6 +2537,7 @@ impl ChatWidget { self.needs_final_message_separator = false; self.had_work_activity = false; self.request_status_line_branch_refresh(); + self.request_status_line_git_summary_refresh(); } // Mark task stopped and request redraw now that all content is in history. self.pending_status_indicator_restore = false; @@ -2960,6 +2999,7 @@ impl ChatWidget { self.plan_stream_controller = None; self.pending_status_indicator_restore = false; self.request_status_line_branch_refresh(); + self.request_status_line_git_summary_refresh(); self.maybe_show_pending_rate_limit_prompt(); } @@ -4768,6 +4808,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, + workspace_command_runner, initial_user_message, enhanced_keys_supported, has_chatgpt_account, @@ -4960,6 +5001,7 @@ impl ChatWidget { feedback, current_rollout_path: None, current_cwd, + workspace_command_runner, instruction_source_paths: Vec::new(), session_network_proxy: None, status_line_invalid_items_warned, @@ -4973,6 +5015,10 @@ impl ChatWidget { status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, + status_line_git_summary: None, + status_line_git_summary_cwd: None, + status_line_git_summary_pending: false, + status_line_git_summary_lookup_complete: false, current_goal_status_indicator: None, current_goal_status: None, goal_status_active_turn_started_at: None, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index c78bfa7602..699b45e053 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -5,6 +5,7 @@ use super::*; use crate::bottom_pane::status_line_from_segments; +use crate::branch_summary; use crate::status::format_tokens_compact; /// Items shown in the terminal title when the user has not configured a @@ -59,6 +60,14 @@ impl StatusSurfaceSelections { .terminal_title_items .contains(&TerminalTitleItem::GitBranch) } + + fn uses_git_summary(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::PullRequestNumber) + || self + .status_line_items + .contains(&StatusLineItem::BranchChanges) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -132,13 +141,24 @@ impl ChatWidget { self.status_line_branch = None; self.status_line_branch_pending = false; self.status_line_branch_lookup_complete = false; - return; + } else { + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + if !self.status_line_branch_lookup_complete { + self.request_status_line_branch(cwd); + } } - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - if !self.status_line_branch_lookup_complete { - self.request_status_line_branch(cwd); + if !selections.uses_git_summary() { + self.status_line_git_summary = None; + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = false; + } else { + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_git_summary_state(&cwd); + if !self.status_line_git_summary_lookup_complete { + self.request_status_line_git_summary(cwd); + } } } @@ -147,6 +167,7 @@ impl ChatWidget { self.bottom_pane.set_status_line_enabled(enabled); if !enabled { self.set_status_line(/*status_line*/ None); + self.set_status_line_hyperlink(/*url*/ None); return; } @@ -161,6 +182,12 @@ impl ChatWidget { segments, self.config.tui_status_line_use_colors, )); + let hyperlink_url = selections + .status_line_items + .contains(&StatusLineItem::PullRequestNumber) + .then(|| self.status_line_pull_request_url()) + .flatten(); + self.set_status_line_hyperlink(hyperlink_url); } /// Clears the terminal title Codex most recently wrote, if any. @@ -348,6 +375,16 @@ impl ChatWidget { self.request_status_line_branch(cwd); } + pub(super) fn request_status_line_git_summary_refresh(&mut self) { + let selections = self.status_surface_selections(); + if !selections.uses_git_summary() { + return; + } + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_git_summary_state(&cwd); + self.request_status_line_git_summary(cwd); + } + /// Parses configured status-line ids into known items and collects unknown ids. /// /// Unknown ids are deduplicated in insertion order for warning messages. @@ -473,6 +510,16 @@ impl ChatWidget { self.status_line_branch_lookup_complete = false; } + fn sync_status_line_git_summary_state(&mut self, cwd: &Path) { + if self.status_line_git_summary_cwd.as_deref() == Some(cwd) { + return; + } + self.status_line_git_summary_cwd = Some(cwd.to_path_buf()); + self.status_line_git_summary = None; + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = false; + } + /// Starts an async git-branch lookup unless one is already running. /// /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject @@ -481,14 +528,34 @@ impl ChatWidget { if self.status_line_branch_pending { return; } + let Some(runner) = self.workspace_command_runner.clone() else { + self.status_line_branch_lookup_complete = true; + return; + }; self.status_line_branch_pending = true; let tx = self.app_event_tx.clone(); tokio::spawn(async move { - let branch = current_branch_name(&cwd).await; + let branch = branch_summary::current_branch_name(runner.as_ref(), &cwd).await; tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); }); } + fn request_status_line_git_summary(&mut self, cwd: PathBuf) { + if self.status_line_git_summary_pending { + return; + } + let Some(runner) = self.workspace_command_runner.clone() else { + self.status_line_git_summary_lookup_complete = true; + return; + }; + self.status_line_git_summary_pending = true; + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let summary = branch_summary::status_line_git_summary(runner.as_ref(), &cwd).await; + tx.send(AppEvent::StatusLineGitSummaryUpdated { cwd, summary }); + }); + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -506,6 +573,22 @@ impl ChatWidget { } StatusLineItem::ProjectRoot => self.status_line_project_root_name(), StatusLineItem::GitBranch => self.status_line_branch.clone(), + StatusLineItem::PullRequestNumber => self + .status_line_git_summary + .as_ref() + .and_then(|summary| summary.pull_request.as_ref()) + .map(|pull_request| format!("PR #{}", pull_request.number)), + StatusLineItem::BranchChanges => self + .status_line_git_summary + .as_ref() + .and_then(|summary| summary.branch_change_stats.as_ref()) + .map(|stats| { + if stats.additions == 0 && stats.deletions == 0 { + "No changes".to_string() + } else { + format!("+{} -{}", stats.additions, stats.deletions) + } + }), StatusLineItem::Status => Some(self.run_state_status_text()), StatusLineItem::UsedTokens => { let usage = self.status_line_total_usage(); @@ -572,6 +655,13 @@ impl ChatWidget { } } + fn status_line_pull_request_url(&self) -> Option { + self.status_line_git_summary + .as_ref() + .and_then(|summary| summary.pull_request.as_ref()) + .map(|pull_request| pull_request.url.clone()) + } + pub(super) fn status_surface_preview_value_for_item( &mut self, item: StatusSurfacePreviewItem, @@ -585,6 +675,8 @@ impl ChatWidget { StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, + StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber, + StatusSurfacePreviewItem::BranchChanges => StatusLineItem::BranchChanges, StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining, StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed, StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04f7e3d907..2631e50659 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -302,6 +302,7 @@ pub(super) async fn make_chatwidget_manual( feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, current_cwd: None, + workspace_command_runner: None, instruction_source_paths: Vec::new(), session_network_proxy: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -315,6 +316,10 @@ pub(super) async fn make_chatwidget_manual( status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, + status_line_git_summary: None, + status_line_git_summary_cwd: None, + status_line_git_summary_pending: false, + status_line_git_summary_lookup_complete: false, current_goal_status_indicator: None, current_goal_status: None, goal_status_active_turn_started_at: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index b6afdf0f75..97cc8d7fc4 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1536,6 +1536,7 @@ async fn make_startup_chat_with_cli_overrides( config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f575349dd0..a2c4f11d50 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -72,6 +72,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 73f0d3b7ac..440b7f9ab8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -131,6 +131,71 @@ async fn token_usage_update_uses_runtime_context_window() { "expected /status to avoid raw config context window, got: {context_line}" ); } + +#[tokio::test] +async fn status_line_git_summary_items_render_values() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_git_summary = Some(StatusLineGitSummary { + pull_request: Some(crate::branch_summary::StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + }), + branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats { + additions: 143, + deletions: 22, + }), + }); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::PullRequestNumber), + Some("PR #20252".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges), + Some("+143 -22".to_string()) + ); +} + +#[tokio::test] +async fn status_line_branch_changes_render_no_changes() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_git_summary = Some(StatusLineGitSummary { + pull_request: None, + branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats { + additions: 0, + deletions: 0, + }), + }); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges), + Some("No changes".to_string()) + ); +} + +#[tokio::test] +async fn stale_status_line_git_summary_update_is_ignored() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_git_summary_cwd = Some(PathBuf::from("/expected")); + chat.status_line_git_summary_pending = true; + + chat.set_status_line_git_summary( + PathBuf::from("/other"), + StatusLineGitSummary { + pull_request: Some(crate::branch_summary::StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + }), + branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats { + additions: 143, + deletions: 22, + }), + }, + ); + + assert!(chat.status_line_git_summary.is_none()); + assert!(!chat.status_line_git_summary_pending); +} #[tokio::test] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); @@ -142,6 +207,7 @@ async fn helpers_are_available_and_do_not_panic() { config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, @@ -1310,6 +1376,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() { #[tokio::test] async fn status_line_branch_refreshes_after_turn_complete() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + install_noop_workspace_command_runner(&mut chat); chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; @@ -1322,6 +1389,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { #[tokio::test] async fn status_line_branch_refreshes_after_interrupt() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + install_noop_workspace_command_runner(&mut chat); chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; @@ -1331,6 +1399,37 @@ async fn status_line_branch_refreshes_after_interrupt() { assert!(chat.status_line_branch_pending); } +fn install_noop_workspace_command_runner(chat: &mut ChatWidget) { + chat.workspace_command_runner = Some(std::sync::Arc::new(NoopWorkspaceCommandRunner)); +} + +struct NoopWorkspaceCommandRunner; + +impl crate::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommandRunner { + fn run( + &self, + _command: crate::workspace_command::WorkspaceCommand, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + crate::workspace_command::WorkspaceCommandOutput, + crate::workspace_command::WorkspaceCommandError, + >, + > + Send + + '_, + >, + > { + Box::pin(async { + Ok(crate::workspace_command::WorkspaceCommandOutput { + exit_code: 1, + stdout: String::new(), + stderr: String::new(), + }) + }) + } +} + #[tokio::test] async fn interrupted_turn_clears_visible_running_hook() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5128d492fb..85aeca9bc5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -103,6 +103,7 @@ mod audio_device { } } mod bottom_pane; +mod branch_summary; mod chatwidget; mod cli; mod clipboard_copy; @@ -188,6 +189,7 @@ mod version; #[cfg(not(target_os = "linux"))] mod voice; mod width; +mod workspace_command; #[cfg(target_os = "linux")] #[allow(dead_code)] mod voice { diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 6f226b5cfe..91362bf5a5 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -61,6 +61,24 @@ use crate::tui::FrameRequester; /// row boundary, which breaks normal terminal URL detection for long URLs that /// wrap across multiple rows. pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + mark_hyperlink_cells(buf, area, url, |cell| { + cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED) + }); +} + +/// Marks any underlined buffer cells as an OSC 8 hyperlink. +pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + mark_hyperlink_cells(buf, area, url, |cell| { + cell.modifier.contains(Modifier::UNDERLINED) + }); +} + +fn mark_hyperlink_cells( + buf: &mut Buffer, + area: Rect, + url: &str, + should_mark: impl Fn(&ratatui::buffer::Cell) -> bool, +) { // Sanitize: strip any characters that could break out of the OSC 8 // sequence (ESC or BEL) to prevent terminal escape injection from a // malformed or compromised upstream URL. @@ -75,8 +93,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { for y in area.top()..area.bottom() { for x in area.left()..area.right() { let cell = &mut buf[(x, y)]; - // Only mark cells that carry the URL's distinctive style. - if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) { + if !should_mark(cell) { continue; } let sym = cell.symbol().to_string(); diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index 63ebdc6926..016d086c54 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -2,5 +2,6 @@ mod auth; mod keys; pub(crate) mod onboarding_screen; mod trust_directory; +pub(crate) use auth::mark_underlined_hyperlink; pub(crate) use auth::mark_url_hyperlink; mod welcome; diff --git a/codex-rs/tui/src/workspace_command.rs b/codex-rs/tui/src/workspace_command.rs new file mode 100644 index 0000000000..f0267699a2 --- /dev/null +++ b/codex-rs/tui/src/workspace_command.rs @@ -0,0 +1,200 @@ +//! App-server-backed workspace command execution for TUI-owned background lookups. +//! +//! This module is the TUI boundary for short, non-interactive commands that need to run wherever +//! the active workspace lives. Callers describe a command in terms of argv, cwd, environment +//! overrides, timeout, and output cap; the runner translates that request to app-server +//! `command/exec`. Keeping this as a TUI-local abstraction lets status surfaces avoid knowing +//! whether the current app-server is embedded or remote. +//! +//! Commands sent through this path are best-effort metadata probes. They should not prompt for +//! stdin, should tolerate failure by omitting optional UI, and should keep output bounded so a +//! status-line refresh cannot grow into an unbounded background process. + +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::RequestId; +use uuid::Uuid; + +/// Shared handle for running workspace commands from TUI components. +pub(crate) type WorkspaceCommandRunner = Arc; + +/// Describes a bounded non-interactive command to execute in the active workspace. +/// +/// The command is intentionally argv-based rather than shell-based so callers do not need to quote +/// user or repository data. `cwd` is interpreted by app-server relative to the workspace rules for +/// the active session, which is what makes the same request shape work for embedded and remote +/// app-server instances. +#[derive(Clone, Debug)] +pub(crate) struct WorkspaceCommand { + /// Program and arguments to execute without shell interpolation. + pub(crate) argv: Vec, + /// Working directory for the command, if different from app-server's session cwd. + pub(crate) cwd: Option, + /// Environment overrides where `None` removes a variable. + pub(crate) env: HashMap>, + /// Maximum wall-clock duration before app-server cancels the command. + pub(crate) timeout: Duration, + /// Maximum captured stdout/stderr bytes returned by app-server. + pub(crate) output_bytes_cap: usize, +} + +impl WorkspaceCommand { + /// Creates a workspace command with conservative defaults for status-style metadata probes. + pub(crate) fn new(argv: impl IntoIterator>) -> Self { + Self { + argv: argv.into_iter().map(Into::into).collect(), + cwd: None, + env: HashMap::new(), + timeout: Duration::from_secs(5), + output_bytes_cap: 64 * 1024, + } + } + + /// Sets the command working directory. + pub(crate) fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + /// Adds or replaces one environment variable override. + pub(crate) fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), Some(value.into())); + self + } +} + +/// Captured result from a completed workspace command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceCommandOutput { + /// Process exit status code reported by app-server. + pub(crate) exit_code: i32, + /// Captured stdout after app-server output capping. + pub(crate) stdout: String, + /// Captured stderr after app-server output capping. + pub(crate) stderr: String, +} + +impl WorkspaceCommandOutput { + /// Returns whether the process exited successfully. + pub(crate) fn success(&self) -> bool { + self.exit_code == 0 + } +} + +/// Transport or protocol failure before a command result was available. +/// +/// Non-zero process exits are represented as `WorkspaceCommandOutput` so callers can distinguish +/// a normal probe miss from an app-server request failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceCommandError { + message: String, +} + +impl WorkspaceCommandError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for WorkspaceCommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for WorkspaceCommandError {} + +/// Executes non-interactive workspace commands through the active TUI app-server session. +/// +/// Implementations decide where the workspace lives. Callers provide argv/cwd/env and should not +/// branch on local versus remote execution. +pub(crate) trait WorkspaceCommandExecutor: Send + Sync { + /// Runs a workspace command and returns captured output or an app-server request error. + /// + /// Callers should treat errors as infrastructure failures and should treat successful output + /// with a non-zero exit code as ordinary command failure. Returning a future instead of using + /// `async_trait` keeps the trait object-safe while matching the repo's native async trait + /// conventions. + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box> + Send + '_>, + >; +} + +/// Workspace command runner that forwards every request to the active app-server. +#[derive(Clone)] +pub(crate) struct AppServerWorkspaceCommandRunner { + request_handle: AppServerRequestHandle, +} + +impl AppServerWorkspaceCommandRunner { + /// Creates a runner from an app-server request handle owned by the current TUI session. + pub(crate) fn new(request_handle: AppServerRequestHandle) -> Self { + Self { request_handle } + } +} + +impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner { + /// Sends the command as a one-off app-server `command/exec` request. + /// + /// The request is non-tty, does not stream stdin/stdout/stderr, and uses the caller's timeout + /// and output cap. It leaves sandbox and permission profile selection to app-server so the same + /// runner follows the active session's embedded or remote execution policy. + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + let timeout_ms = i64::try_from(command.timeout.as_millis()).unwrap_or(i64::MAX); + let env = if command.env.is_empty() { + None + } else { + Some(command.env) + }; + let response: CommandExecResponse = self + .request_handle + .request_typed(ClientRequest::OneOffCommandExec { + request_id: RequestId::String(format!("workspace-command-{}", Uuid::new_v4())), + params: CommandExecParams { + command: command.argv, + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(command.output_bytes_cap), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(timeout_ms), + cwd: command.cwd, + env, + size: None, + sandbox_policy: None, + permission_profile: None, + }, + }) + .await + .map_err(|err| WorkspaceCommandError::new(err.to_string()))?; + + Ok(WorkspaceCommandOutput { + exit_code: response.exit_code, + stdout: response.stdout, + stderr: response.stderr, + }) + }) + } +} From 48402be6fa5c218f3b8b994206c4d389cca6bae5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 4 May 2026 16:18:56 -0300 Subject: [PATCH 7/7] feat(tui): improve TUI keymap coverage (#20798) ## Summary - normalize terminal-emitted C0 control characters through configurable editor keymaps, covering raw control-key fallbacks like Shift+Enter-as-LF in terminals from #20555 and #20898, plus part of the modified-Enter behavior in #20580 - add default-unbound keymap actions for toggling Fast mode and killing the current composer line, giving #20698 users a configurable zsh-style Ctrl+U option without changing the existing default Ctrl+U behavior - wire the new actions through gated /keymap picker entries, schema generation, and snapshot coverage Fixes #20555. Fixes #20898. ## Testing - just write-config-schema - just fmt - cargo test -p codex-config - cargo test -p codex-tui keymap::tests - cargo test -p codex-tui bottom_pane::textarea::tests - cargo test -p codex-tui keymap_setup::tests - cargo insta pending-snapshots - just fix -p codex-tui - git diff --check - just argument-comment-lint --- codex-rs/config/src/tui_keymap.rs | 4 + codex-rs/core/config.schema.json | 20 +++ codex-rs/tui/src/app/input.rs | 8 ++ codex-rs/tui/src/bottom_pane/textarea.rs | 64 ++++++++- codex-rs/tui/src/chatwidget.rs | 15 +++ codex-rs/tui/src/chatwidget/keymap_picker.rs | 15 ++- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 7 +- .../src/chatwidget/tests/slash_commands.rs | 45 +++++++ codex-rs/tui/src/key_hint.rs | 93 ++++++++++--- codex-rs/tui/src/keymap.rs | 95 +++++++++++++ codex-rs/tui/src/keymap_setup.rs | 127 ++++++++++++++++-- codex-rs/tui/src/keymap_setup/actions.rs | 44 ++++++ codex-rs/tui/src/keymap_setup/picker.rs | 46 ++++++- ...ap_setup__tests__keymap_picker_custom.snap | 4 +- ...ests__keymap_picker_fast_mode_enabled.snap | 22 +++ ...p__tests__keymap_picker_first_actions.snap | 6 +- ...ap_setup__tests__keymap_picker_narrow.snap | 4 +- ...ymap_setup__tests__keymap_picker_wide.snap | 4 +- 18 files changed, 572 insertions(+), 51 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index b23322a538..fcce1fa8ab 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -104,6 +104,8 @@ pub struct TuiGlobalKeymap { pub toggle_shortcuts: Option, /// Toggle Vim mode for the composer input. pub toggle_vim_mode: Option, + /// Toggle Fast mode. + pub toggle_fast_mode: Option, } /// Chat context keybindings. @@ -169,6 +171,8 @@ pub struct TuiEditorKeymap { pub delete_forward_word: Option, /// Kill text from cursor to line start. pub kill_line_start: Option, + /// Kill the current line. + pub kill_whole_line: Option, /// Kill text from cursor to line end. pub kill_line_end: Option, /// Yank the kill buffer. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a75e287134..824f542b7e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2441,6 +2441,7 @@ "insert_newline": null, "kill_line_end": null, "kill_line_start": null, + "kill_whole_line": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -2458,6 +2459,7 @@ "open_transcript": null, "queue": null, "submit": null, + "toggle_fast_mode": null, "toggle_shortcuts": null, "toggle_vim_mode": null }, @@ -2811,6 +2813,14 @@ ], "description": "Kill text from cursor to line start." }, + "kill_whole_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill the current line." + }, "move_down": { "allOf": [ { @@ -2938,6 +2948,14 @@ ], "description": "Submit the current composer draft." }, + "toggle_fast_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle Fast mode." + }, "toggle_shortcuts": { "allOf": [ { @@ -3018,6 +3036,7 @@ "insert_newline": null, "kill_line_end": null, "kill_line_start": null, + "kill_whole_line": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -3042,6 +3061,7 @@ "open_transcript": null, "queue": null, "submit": null, + "toggle_fast_mode": null, "toggle_shortcuts": null, "toggle_vim_mode": null } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index a9cce1f353..f223db9bb3 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -129,6 +129,14 @@ impl App { return; } + if app_keymap_shortcuts_available + && self.keymap.app.toggle_fast_mode.is_pressed(key_event) + && self.chat_widget.can_toggle_fast_mode_from_keybinding() + { + self.chat_widget.toggle_fast_mode_from_ui(); + return; + } + if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 268665ef06..c6c756e3e0 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -569,6 +569,10 @@ impl TextArea { self.kill_to_beginning_of_line(); return; } + if keymap.kill_whole_line.is_pressed(event) { + self.kill_current_line(); + return; + } if keymap.kill_line_end.is_pressed(event) { self.kill_to_end_of_line(); return; @@ -780,7 +784,7 @@ impl TextArea { fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) { - self.delete_current_line(); + self.kill_current_line(); return true; } if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) { @@ -1116,7 +1120,7 @@ impl TextArea { self.yank_line_range(range); } - fn delete_current_line(&mut self) { + fn kill_current_line(&mut self) { let range = self.current_line_range_with_newline(); self.kill_line_range(range); } @@ -2447,6 +2451,51 @@ mod tests { assert_eq!(t.cursor(), 3); } + #[test] + fn kill_current_line_removes_current_line_linewise() { + let mut t = ta_with("abc\ndef\nghi"); + t.set_cursor(/*pos*/ 5); + + t.kill_current_line(); + + assert_eq!(t.text(), "abc\nghi"); + assert_eq!(t.cursor(), 4); + assert_eq!(t.kill_buffer, "def\n"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + + #[test] + fn kill_current_line_keeps_previous_newline_for_final_line() { + let mut t = ta_with("abc\ndef"); + t.set_cursor(/*pos*/ 5); + + t.kill_current_line(); + + assert_eq!(t.text(), "abc\n"); + assert_eq!(t.cursor(), 4); + assert_eq!(t.kill_buffer, "def"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + + #[test] + fn kill_whole_line_keymap_dispatch_uses_linewise_kill() { + let mut t = ta_with("abc\ndef\nghi"); + t.set_cursor(/*pos*/ 5); + let mut keymap = RuntimeKeymap::defaults().editor; + keymap.kill_line_start.clear(); + keymap.kill_whole_line = vec![key_hint::ctrl(KeyCode::Char('u'))]; + + t.input_with_keymap( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + &keymap, + ); + + assert_eq!(t.text(), "abc\nghi"); + assert_eq!(t.cursor(), 4); + assert_eq!(t.kill_buffer, "def\n"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + #[test] fn delete_forward_word_variants() { let mut t = ta_with("hello world "); @@ -2668,6 +2717,17 @@ mod tests { assert_eq!(t.cursor(), 2); } + #[test] + fn c0_line_feed_inserts_newline_through_insert_newline_keymap() { + let mut t = ta_with("ab"); + t.set_cursor(/*pos*/ 1); + + t.input(KeyEvent::new(KeyCode::Char('\u{000a}'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a\nb"); + assert_eq!(t.cursor(), 2); + } + #[test] fn c0_control_chars_respect_unbound_editor_movement() { let mut t = ta_with("a\nb"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6d15d64aab..8a61e79fe0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9313,6 +9313,12 @@ impl ChatWidget { self.config.features.enabled(Feature::FastMode) } + pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool { + self.fast_mode_enabled() + && !self.is_user_turn_pending_or_running() + && self.bottom_pane.no_modal_or_popup_active() + } + pub(crate) fn set_realtime_audio_device( &mut self, kind: RealtimeAudioDeviceKind, @@ -9367,6 +9373,15 @@ impl ChatWidget { .send(AppEvent::PersistServiceTierSelection { service_tier }); } + pub(crate) fn toggle_fast_mode_from_ui(&mut self) { + let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { + None + } else { + Some(ServiceTier::Fast) + }; + self.set_service_tier_selection(next_tier); + } + pub(crate) fn current_model(&self) -> &str { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.model(); diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 7e0b3811e4..8e5f01f04a 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -30,9 +30,10 @@ impl ChatWidget { pub(crate) fn open_keymap_picker(&mut self) { match RuntimeKeymap::from_config(&self.config.tui_keymap) { Ok(runtime_keymap) => { - let params = keymap_setup::build_keymap_picker_params( + let params = keymap_setup::build_keymap_picker_params_with_filter( &runtime_keymap, &self.config.tui_keymap, + self.keymap_action_filter(), ); self.bottom_pane.show_selection_view(params); } @@ -120,9 +121,10 @@ impl ChatWidget { action: &str, runtime_keymap: &RuntimeKeymap, ) { - let params = keymap_setup::build_keymap_picker_params_for_selected_action( + let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter( runtime_keymap, &self.config.tui_keymap, + self.keymap_action_filter(), context, action, ); @@ -135,9 +137,10 @@ impl ChatWidget { params, ); if !replaced { - let params = keymap_setup::build_keymap_picker_params_for_selected_action( + let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter( runtime_keymap, &self.config.tui_keymap, + self.keymap_action_filter(), context, action, ); @@ -146,6 +149,12 @@ impl ChatWidget { self.request_redraw(); } + fn keymap_action_filter(&self) -> keymap_setup::KeymapActionFilter { + keymap_setup::KeymapActionFilter { + fast_mode_enabled: self.fast_mode_enabled(), + } + } + /// Applies a committed keymap edit to the live chat widget. /// /// The caller is responsible for persisting the config file before invoking this method. This diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index a6cc50feb0..aaa71cca80 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -178,12 +178,7 @@ impl ChatWidget { self.open_model_popup(); } SlashCommand::Fast => { - let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - None - } else { - Some(ServiceTier::Fast) - }; - self.set_service_tier_selection(next_tier); + self.toggle_fast_mode_from_ui(); } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index b376b47828..84cc6e84a5 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1817,6 +1817,51 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + + chat.toggle_fast_mode_from_ui(); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::CodexOp(Op::OverrideTurnContext { + service_tier: Some(Some(ServiceTier::Fast)), + .. + }) + )), + "expected fast-mode override app event; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistServiceTierSelection { + service_tier: Some(ServiceTier::Fast), + } + )), + "expected fast-mode persistence app event; events: {events:?}" + ); + + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +async fn fast_keybinding_toggle_requires_feature_and_idle_surface() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false); + + assert!(!chat.can_toggle_fast_mode_from_keybinding()); + + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + assert!(chat.can_toggle_fast_mode_from_keybinding()); + + chat.bottom_pane.set_task_running(/*running*/ true); + assert!(!chat.can_toggle_fast_mode_from_keybinding()); +} + #[tokio::test] async fn user_turn_carries_service_tier_after_fast_toggle() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 1d32707e6e..1008a00aa5 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -2,7 +2,8 @@ //! //! This module provides `KeyBinding`, the runtime representation of a single //! keybinding (key code + modifier set), along with matching logic that handles -//! cross-terminal inconsistencies in how shifted letters are reported. +//! cross-terminal inconsistencies in how shifted letters and raw C0 control +//! characters are reported. //! //! It also supplies rendering helpers that convert bindings into styled //! `ratatui::text::Span` values for UI hint display. @@ -26,10 +27,10 @@ const SHIFT_PREFIX: &str = "shift + "; /// One concrete key event that can trigger a TUI action. /// -/// Matching via `is_press` handles both exact equality and a shifted-letter -/// compatibility fallback for terminals that report uppercase letters without -/// the SHIFT modifier flag. This means a binding defined as `shift-a` will -/// match a terminal event of either `Shift+a` or plain `A`. +/// Matching via `is_press` handles exact equality plus compatibility fallbacks +/// for terminals that report uppercase letters without SHIFT and Ctrl keys as +/// raw C0 control characters. This means a binding defined as `shift-a` will +/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF. /// /// This does not model multi-key chords or partial matches; callers that need /// sequences must keep that state outside this type. @@ -45,13 +46,13 @@ impl KeyBinding { } pub(crate) fn from_event(event: KeyEvent) -> Self { - let (key, modifiers) = normalize_shifted_ascii_char(event.code, event.modifiers); + let (key, modifiers) = normalize_key_parts(event.code, event.modifiers); Self { key, modifiers } } pub fn is_press(&self, event: KeyEvent) -> bool { - normalize_shifted_ascii_char(self.key, self.modifiers) - == normalize_shifted_ascii_char(event.code, event.modifiers) + normalize_key_parts(self.key, self.modifiers) + == normalize_key_parts(event.code, event.modifiers) && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat) } @@ -76,7 +77,7 @@ impl KeyBinding { } } -fn normalize_shifted_ascii_char( +pub(crate) fn normalize_key_parts( key: KeyCode, mut modifiers: KeyModifiers, ) -> (KeyCode, KeyModifiers) { @@ -96,13 +97,11 @@ fn normalize_shifted_ascii_char( } fn c0_control_char_to_ctrl_char(ch: char) -> Option { - match ch { - '\u{0002}' => Some('b'), - '\u{0006}' => Some('f'), - '\u{000e}' => Some('n'), - '\u{0010}' => Some('p'), - '\u{0012}' => Some('r'), - '\u{0013}' => Some('s'), + let code = u32::from(ch); + match code { + 0x00 => Some(' '), + 0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')), + 0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')), _ => None, } } @@ -256,6 +255,68 @@ mod tests { assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT))); } + #[test] + fn ctrl_bindings_match_all_supported_c0_control_char_events() { + let cases = [ + (' ', '\u{0000}'), + ('a', '\u{0001}'), + ('b', '\u{0002}'), + ('c', '\u{0003}'), + ('d', '\u{0004}'), + ('e', '\u{0005}'), + ('f', '\u{0006}'), + ('g', '\u{0007}'), + ('h', '\u{0008}'), + ('i', '\u{0009}'), + ('j', '\u{000a}'), + ('k', '\u{000b}'), + ('l', '\u{000c}'), + ('m', '\u{000d}'), + ('n', '\u{000e}'), + ('o', '\u{000f}'), + ('p', '\u{0010}'), + ('q', '\u{0011}'), + ('r', '\u{0012}'), + ('s', '\u{0013}'), + ('t', '\u{0014}'), + ('u', '\u{0015}'), + ('v', '\u{0016}'), + ('w', '\u{0017}'), + ('x', '\u{0018}'), + ('y', '\u{0019}'), + ('z', '\u{001a}'), + ('4', '\u{001c}'), + ('5', '\u{001d}'), + ('6', '\u{001e}'), + ('7', '\u{001f}'), + ]; + + for (ctrl_char, c0_char) in cases { + assert!( + ctrl(KeyCode::Char(ctrl_char)) + .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)), + "expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}" + ); + assert!( + !ctrl(KeyCode::Char(ctrl_char)) + .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)), + "expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}" + ); + } + } + + #[test] + fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() { + assert!( + !ctrl(KeyCode::Char('[')) + .is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,)) + ); + assert!( + !ctrl(KeyCode::Char('?')) + .is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,)) + ); + } + #[test] fn history_search_ctrl_bindings_match_c0_control_char_events() { assert!( diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index ec5264030f..419a5ae8cb 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -63,6 +63,8 @@ pub(crate) struct AppKeymap { pub(crate) clear_terminal: Vec, /// Toggle Vim mode for the composer input. pub(crate) toggle_vim_mode: Vec, + /// Toggle Fast mode. + pub(crate) toggle_fast_mode: Vec, } /// Chat-level keybindings evaluated at the app event layer. @@ -120,6 +122,7 @@ pub(crate) struct EditorKeymap { pub(crate) delete_backward_word: Vec, pub(crate) delete_forward_word: Vec, pub(crate) kill_line_start: Vec, + pub(crate) kill_whole_line: Vec, pub(crate) kill_line_end: Vec, pub(crate) yank: Vec, } @@ -369,6 +372,11 @@ impl RuntimeKeymap { &defaults.app.toggle_vim_mode, "tui.keymap.global.toggle_vim_mode", )?, + toggle_fast_mode: resolve_bindings( + keymap.global.toggle_fast_mode.as_ref(), + &defaults.app.toggle_fast_mode, + "tui.keymap.global.toggle_fast_mode", + )?, }; let chat = ChatKeymap { @@ -417,6 +425,7 @@ impl RuntimeKeymap { delete_backward_word: resolve_local!(keymap, defaults, editor, delete_backward_word), delete_forward_word: resolve_local!(keymap, defaults, editor, delete_forward_word), kill_line_start: resolve_local!(keymap, defaults, editor, kill_line_start), + kill_whole_line: resolve_local!(keymap, defaults, editor, kill_whole_line), kill_line_end: resolve_local!(keymap, defaults, editor, kill_line_end), yank: resolve_local!(keymap, defaults, editor, yank), }; @@ -536,6 +545,7 @@ impl RuntimeKeymap { copy: default_bindings![ctrl(KeyCode::Char('o'))], clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], toggle_vim_mode: default_bindings![], + toggle_fast_mode: default_bindings![], }, chat: ChatKeymap { decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], @@ -594,6 +604,7 @@ impl RuntimeKeymap { alt(KeyCode::Char('d')) ], kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))], + kill_whole_line: default_bindings![], kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))], yank: default_bindings![ctrl(KeyCode::Char('y'))], }, @@ -727,6 +738,7 @@ impl RuntimeKeymap { ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -767,6 +779,7 @@ impl RuntimeKeymap { ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -808,6 +821,7 @@ impl RuntimeKeymap { ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), @@ -856,6 +870,7 @@ impl RuntimeKeymap { ), ("composer.submit", self.composer.submit.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), @@ -903,6 +918,10 @@ impl RuntimeKeymap { "editor.kill_line_start", self.editor.kill_line_start.as_slice(), ), + ( + "editor.kill_whole_line", + self.editor.kill_whole_line.as_slice(), + ), ("editor.kill_line_end", self.editor.kill_line_end.as_slice()), ("editor.yank", self.editor.yank.as_slice()), ], @@ -936,6 +955,7 @@ impl RuntimeKeymap { self.editor.delete_forward_word.as_slice(), ), ("kill_line_start", self.editor.kill_line_start.as_slice()), + ("kill_whole_line", self.editor.kill_whole_line.as_slice()), ("kill_line_end", self.editor.kill_line_end.as_slice()), ("yank", self.editor.yank.as_slice()), ], @@ -1389,6 +1409,7 @@ fn parse_keybinding(spec: &str) -> Option { "page-up" => KeyCode::PageUp, "page-down" => KeyCode::PageDown, "space" => KeyCode::Char(' '), + "minus" => KeyCode::Char('-'), other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])), other if other.starts_with('f') => { let number = other[1..].parse::().ok()?; @@ -1587,6 +1608,7 @@ mod tests { runtime.app.clear_terminal, vec![key_hint::ctrl(KeyCode::Char('l'))] ); + assert_eq!(runtime.app.toggle_fast_mode, Vec::new()); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![key_hint::alt(KeyCode::Char(','))] @@ -1607,6 +1629,7 @@ mod tests { runtime.composer.history_search_next, vec![key_hint::ctrl(KeyCode::Char('s'))] ); + assert_eq!(runtime.editor.kill_whole_line, Vec::new()); } #[test] @@ -1732,6 +1755,61 @@ mod tests { assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]); } + #[test] + fn kill_whole_line_can_be_assigned_without_default_binding() { + let mut keymap = TuiKeymap::default(); + keymap.editor.kill_whole_line = Some(one("ctrl-shift-u")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + + assert_eq!( + runtime.editor.kill_whole_line, + vec![KeyBinding::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )] + ); + } + + #[test] + fn kill_whole_line_conflicts_until_kill_line_start_is_unbound() { + let mut keymap = TuiKeymap::default(); + keymap.editor.kill_whole_line = Some(one("ctrl-u")); + + expect_conflict(&keymap, "kill_line_start", "kill_whole_line"); + + keymap.editor.kill_line_start = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free"); + assert_eq!( + runtime.editor.kill_whole_line, + vec![key_hint::ctrl(KeyCode::Char('u'))] + ); + } + + #[test] + fn toggle_fast_mode_can_be_assigned_without_default_binding() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_fast_mode = Some(one("ctrl-shift-f")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + + assert_eq!( + runtime.app.toggle_fast_mode, + vec![KeyBinding::new( + KeyCode::Char('f'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )] + ); + } + + #[test] + fn toggle_fast_mode_conflicts_with_existing_main_surface_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_fast_mode = Some(one("ctrl-l")); + + expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode"); + } + #[test] fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() { let mut keymap = TuiKeymap::default(); @@ -1773,6 +1851,7 @@ mod tests { ("page-up", KeyCode::PageUp), ("page-down", KeyCode::PageDown), ("space", KeyCode::Char(' ')), + ("minus", KeyCode::Char('-')), ]; for (spec, expected_key) in cases { @@ -1790,6 +1869,22 @@ mod tests { assert_eq!(parse_keybinding("ff"), None); } + #[test] + fn parses_minus_alias_and_legacy_literal_minus() { + assert_eq!( + parse_keybinding("alt-minus").map(|binding| binding.parts()), + Some((KeyCode::Char('-'), KeyModifiers::ALT)) + ); + assert_eq!( + parse_keybinding("alt--").map(|binding| binding.parts()), + Some((KeyCode::Char('-'), KeyModifiers::ALT)) + ); + assert_eq!( + parse_keybinding("-").map(|binding| binding.parts()), + Some((KeyCode::Char('-'), KeyModifiers::NONE)) + ); + } + #[test] fn explicit_empty_array_unbinds_action() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index c1ae82c3e8..f67ea20cb0 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -21,10 +21,15 @@ mod actions; mod debug; mod picker; +pub(crate) use actions::KeymapActionFilter; pub(crate) use debug::build_keymap_debug_view; pub(crate) use picker::KEYMAP_PICKER_VIEW_ID; +#[cfg(test)] pub(crate) use picker::build_keymap_picker_params; +#[cfg(test)] pub(crate) use picker::build_keymap_picker_params_for_selected_action; +pub(crate) use picker::build_keymap_picker_params_for_selected_action_with_filter; +pub(crate) use picker::build_keymap_picker_params_with_filter; use codex_config::types::KeybindingSpec; use codex_config::types::KeybindingsSpec; @@ -708,6 +713,9 @@ fn key_parts_to_config_key_spec( code: KeyCode, mut modifiers: KeyModifiers, ) -> Result { + let (code, normalized_modifiers) = crate::key_hint::normalize_key_parts(code, modifiers); + modifiers = normalized_modifiers; + let supported_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT; if !modifiers.difference(supported_modifiers).is_empty() { return Err( @@ -738,7 +746,7 @@ fn key_parts_to_config_key_spec( KeyCode::Char(' ') => "space".to_string(), KeyCode::Char(mut ch) => { if ch == '-' { - return Err("The `-` key cannot be represented in `tui.keymap` yet.".to_string()); + return Ok(format_key_spec(modifiers, "minus")); } if !ch.is_ascii() || ch.is_ascii_control() { return Err("Only printable ASCII keys can be stored in `tui.keymap`.".to_string()); @@ -754,18 +762,22 @@ fn key_parts_to_config_key_spec( } }; + Ok(format_key_spec(modifiers, &key)) +} + +fn format_key_spec(modifiers: KeyModifiers, key: &str) -> String { let mut parts = Vec::new(); if modifiers.contains(KeyModifiers::CONTROL) { - parts.push("ctrl".to_string()); + parts.push("ctrl"); } if modifiers.contains(KeyModifiers::ALT) { - parts.push("alt".to_string()); + parts.push("alt"); } if modifiers.contains(KeyModifiers::SHIFT) { - parts.push("shift".to_string()); + parts.push("shift"); } parts.push(key); - Ok(parts.join("-")) + parts.join("-") } #[cfg(test)] @@ -821,6 +833,12 @@ mod tests { render_buffer(&buf) } + fn fast_mode_action_filter() -> KeymapActionFilter { + KeymapActionFilter { + fast_mode_enabled: true, + } + } + fn render_buffer(buf: &Buffer) -> String { let area = buf.area(); (0..area.height) @@ -891,7 +909,11 @@ mod tests { #[test] fn picker_covers_every_replaceable_action() { let runtime = RuntimeKeymap::defaults(); - let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let params = build_keymap_picker_params_with_filter( + &runtime, + &TuiKeymap::default(), + fast_mode_action_filter(), + ); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); assert!(params.items.is_empty()); @@ -913,6 +935,57 @@ mod tests { })); } + #[test] + fn picker_hides_fast_mode_action_when_feature_is_disabled() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + + assert!( + all_tab + .items + .iter() + .all(|item| item.name != "Toggle Fast Mode") + ); + } + + #[test] + fn picker_shows_fast_mode_action_when_feature_is_enabled() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params_with_filter( + &runtime, + &TuiKeymap::default(), + fast_mode_action_filter(), + ); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + let common_tab = selection_tab(¶ms, KEYMAP_COMMON_TAB_ID); + let app_tab = selection_tab(¶ms, "app-shortcuts"); + let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); + + for tab in [all_tab, common_tab, app_tab, unbound_tab] { + assert!( + tab.items.iter().any(|item| item.name == "Toggle Fast Mode"), + "expected Toggle Fast Mode in {}", + tab.label + ); + } + } + + #[test] + fn keymap_picker_fast_mode_enabled_snapshot() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params_with_filter( + &runtime, + &TuiKeymap::default(), + fast_mode_action_filter(), + ); + + assert_snapshot!( + "keymap_picker_fast_mode_enabled", + render_picker(params, /*width*/ 120) + ); + } + #[test] fn picker_common_tab_lists_curated_actions() { let runtime = RuntimeKeymap::defaults(); @@ -1050,10 +1123,13 @@ mod tests { let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); - assert_eq!(unbound_tab.items.len(), 1); + assert_eq!(unbound_tab.items.len(), 2); assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode"); assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound")); assert!(!unbound_tab.items[0].is_disabled); + assert_eq!(unbound_tab.items[1].name, "Kill Whole Line"); + assert_eq!(unbound_tab.items[1].description.as_deref(), Some("unbound")); + assert!(!unbound_tab.items[1].is_disabled); } #[test] @@ -1534,21 +1610,46 @@ mod tests { } #[test] - fn key_capture_serializes_c0_control_fallbacks() { + fn key_capture_serializes_c0_control_chars_as_ctrl_bindings() { + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('\u{000a}'), + KeyModifiers::NONE, + )), + Ok("ctrl-j".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('\u{0015}'), + KeyModifiers::NONE, + )), + Ok("ctrl-u".to_string()) + ); assert_eq!( key_event_to_config_key_spec(KeyEvent::new( KeyCode::Char('\u{0010}'), - KeyModifiers::NONE + KeyModifiers::NONE, )), Ok("ctrl-p".to_string()) ); } #[test] - fn key_capture_rejects_unrepresentable_keys() { - assert!( - key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) - .is_err() + fn key_capture_serializes_minus_as_named_key() { + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)), + Ok("minus".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::ALT)), + Ok("alt-minus".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('-'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )), + Ok("ctrl-alt-minus".to_string()) ); } diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 66103ab890..b6f9660bdd 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -30,6 +30,8 @@ pub(super) struct KeymapActionDescriptor { pub(super) action: &'static str, /// Short user-facing explanation of what the action does. pub(super) description: &'static str, + /// Feature required before the action appears in `/keymap`. + required_feature: Option, } const fn action( @@ -43,6 +45,42 @@ const fn action( context_label, action, description, + required_feature: None, + } +} + +const fn gated_action( + context: &'static str, + context_label: &'static str, + action: &'static str, + description: &'static str, + required_feature: KeymapActionFeature, +) -> KeymapActionDescriptor { + KeymapActionDescriptor { + context, + context_label, + action, + description, + required_feature: Some(required_feature), + } +} + +#[derive(Clone, Copy, Debug)] +enum KeymapActionFeature { + FastMode, +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct KeymapActionFilter { + pub(crate) fast_mode_enabled: bool, +} + +impl KeymapActionDescriptor { + pub(super) fn is_visible(self, filter: KeymapActionFilter) -> bool { + match self.required_feature { + None => true, + Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled, + } } } @@ -53,6 +91,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "copy", "Copy the last agent response to the clipboard."), action("global", "Global", "clear_terminal", "Clear the terminal UI."), action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), + gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -75,6 +114,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("editor", "Editor", "delete_backward_word", "Delete the previous word."), action("editor", "Editor", "delete_forward_word", "Delete the next word."), action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."), + action("editor", "Editor", "kill_whole_line", "Delete the current line."), action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."), action("editor", "Editor", "yank", "Paste the kill buffer."), action("vim_normal", "Vim normal", "enter_insert", "Enter insert mode at the cursor."), @@ -172,6 +212,7 @@ pub(super) fn binding_slot<'a>( ("global", "copy") => Some(&mut keymap.global.copy), ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), + ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -194,6 +235,7 @@ pub(super) fn binding_slot<'a>( ("editor", "delete_backward_word") => Some(&mut keymap.editor.delete_backward_word), ("editor", "delete_forward_word") => Some(&mut keymap.editor.delete_forward_word), ("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start), + ("editor", "kill_whole_line") => Some(&mut keymap.editor.kill_whole_line), ("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end), ("editor", "yank") => Some(&mut keymap.editor.yank), ("vim_normal", "enter_insert") => Some(&mut keymap.vim_normal.enter_insert), @@ -273,6 +315,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "copy") => Some(runtime_keymap.app.copy.as_slice()), ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), + ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), @@ -295,6 +338,7 @@ pub(super) fn bindings_for_action<'a>( ("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()), ("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()), ("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()), + ("editor", "kill_whole_line") => Some(runtime_keymap.editor.kill_whole_line.as_slice()), ("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()), ("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()), ("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index c46d22cc9b..ed62d0c2e5 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -17,6 +17,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use super::actions::KEYMAP_ACTIONS; +use super::actions::KeymapActionFilter; use super::actions::action_label; use super::actions::bindings_for_action; use super::actions::format_binding_summary; @@ -59,6 +60,7 @@ const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "submit"), ("editor", "insert_newline"), ("composer", "queue"), + ("global", "toggle_fast_mode"), ("global", "open_external_editor"), ("global", "copy"), ("global", "toggle_vim_mode"), @@ -116,32 +118,69 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ }, ]; +#[cfg(test)] pub(crate) fn build_keymap_picker_params( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, +) -> SelectionViewParams { + build_keymap_picker_params_with_filter( + runtime_keymap, + keymap_config, + KeymapActionFilter::default(), + ) +} + +pub(crate) fn build_keymap_picker_params_with_filter( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, ) -> SelectionViewParams { build_keymap_picker_params_for_action( runtime_keymap, keymap_config, + action_filter, /*selected_action*/ None, ) } +#[cfg(test)] pub(crate) fn build_keymap_picker_params_for_selected_action( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, context: &str, action: &str, ) -> SelectionViewParams { - build_keymap_picker_params_for_action(runtime_keymap, keymap_config, Some((context, action))) + build_keymap_picker_params_for_selected_action_with_filter( + runtime_keymap, + keymap_config, + KeymapActionFilter::default(), + context, + action, + ) +} + +pub(crate) fn build_keymap_picker_params_for_selected_action_with_filter( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, + context: &str, + action: &str, +) -> SelectionViewParams { + build_keymap_picker_params_for_action( + runtime_keymap, + keymap_config, + action_filter, + Some((context, action)), + ) } fn build_keymap_picker_params_for_action( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, selected_action: Option<(&str, &str)>, ) -> SelectionViewParams { - let rows = build_keymap_rows(runtime_keymap, keymap_config); + let rows = build_keymap_rows(runtime_keymap, keymap_config, action_filter); let total = rows.len(); let custom_count = rows.iter().filter(|row| row.custom_binding).count(); let unbound_count = rows.iter().filter(|row| row.is_unbound()).count(); @@ -287,9 +326,12 @@ fn keymap_debug_tab() -> SelectionTab { fn build_keymap_rows( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, ) -> Vec { KEYMAP_ACTIONS .iter() + .copied() + .filter(|descriptor| descriptor.is_visible(action_filter)) .map(|descriptor| { let bindings = bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index b0336cd207..ab398780e4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,9 +5,9 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 85 actions, 1 customized, 1 unbound. + 86 actions, 1 customized, 2 unbound. - [All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval Debug + [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts › Global Open Transcript ctrl-t diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap new file mode 100644 index 0000000000..7538c21500 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 87 actions, 0 customized, 3 unbound. + + [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global - Toggle Fast Mode unbound + Chat Decrease Reasoning Effort alt-, + Chat Increase Reasoning Effort alt-. + + left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 7831d32ec9..24b8d81b77 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,13 +2,13 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (85 selectable) +tab: All (86 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) -tab: Unbound (1) (1 selectable) +tab: Unbound (2) (2 selectable) tab: App (8 selectable) tab: Composer (5 selectable) -tab: Editor (16 selectable) +tab: Editor (17 selectable) tab: Vim (34 selectable) tab: Navigation (14 selectable) tab: Approval (8 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 36503cbe08..9132b3403d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,9 +5,9 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 85 actions, 0 customized, 1 unbound. + 86 actions, 0 customized, 2 unbound. - [All] Common Customized (0) Unbound (1) App Composer Editor Vim + [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index c930200730..b6cc5dfa2d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,9 +5,9 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 85 actions, 0 customized, 1 unbound. + 86 actions, 0 customized, 2 unbound. - [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Debug + [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts › Global Open Transcript ctrl-t