diff --git a/.bazelrc b/.bazelrc index a068b44840..a5fea40281 100644 --- a/.bazelrc +++ b/.bazelrc @@ -79,6 +79,10 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel +# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests +# are not stable in that setup yet. Keep running the rest of the Rust +# integration suites through the workspace-root launcher. +common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect @@ -164,9 +168,9 @@ common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 -# Native Windows CI still covers these tests. The cross-built gnullvm binaries -# currently crash in V8-backed code-mode tests and hang in PowerShell AST parser -# tests when those binaries are run on the Windows runner. +# Native Windows CI still covers the PowerShell tests. The cross-built gnullvm +# binaries currently hang in PowerShell AST parser tests when those binaries are +# run on the Windows runner. common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index d98bd4f874..aed3f7a1b3 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -18,9 +18,9 @@ concurrency: jobs: test: # PRs use a fast Windows cross-compiled test leg for pre-merge signal. - # Post-merge pushes to main also run the native Windows test job below, - # which keeps V8/code-mode coverage without putting PR latency back on the - # critical path. + # Post-merge pushes to main also run the native Windows test job below for + # broader Windows signal without putting PR latency back on the critical + # path. Cargo CI owns V8/code-mode test coverage for now. timeout-minutes: 30 strategy: fail-fast: false @@ -46,8 +46,8 @@ jobs: # Windows fast path: build the windows-gnullvm binaries with Linux # RBE, then run the resulting Windows tests on the Windows runner. - # The main-only native Windows job below preserves full V8/code-mode - # coverage post-merge. + # Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad + # non-code-mode signal. - os: windows-latest target: x86_64-pc-windows-gnullvm runs-on: ${{ matrix.os }} @@ -88,6 +88,11 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all + # V8-backed code-mode tests are covered by Cargo CI. Bazel CI + # cross-compiles in several legs, and those tests are not stable in + # that setup yet. + -//codex-rs/code-mode:code-mode-unit-tests + -//codex-rs/v8-poc:v8-poc-unit-tests ) bazel_wrapper_args=( @@ -105,15 +110,6 @@ jobs: --windows-cross-compile --remote-download-toplevel ) - # Tradeoff: the Linux-RBE-built windows-gnullvm V8 archive - # currently crashes during direct V8/code-mode smoke tests on the - # Windows runner. Keep the broader fast Windows suite in PR CI and - # rely on the main-only native Windows job below for full - # V8/code-mode signal while we investigate the cross-built archive. - bazel_targets+=( - -//codex-rs/code-mode:code-mode-unit-tests - -//codex-rs/v8-poc:v8-poc-unit-tests - ) fi ./.github/scripts/run-bazel-ci.sh \ @@ -144,9 +140,8 @@ jobs: test-windows-native-main: # Native Windows Bazel tests are slower and frequently approach the - # 30-minute PR budget, but they provide the full V8/code-mode signal that - # the fast cross-compiled PR leg intentionally trades away. Run this only - # for post-merge commits to main and give it a larger timeout. + # 30-minute PR budget. Run this only for post-merge commits to main and give + # it a larger timeout. if: github.event_name == 'push' && github.ref == 'refs/heads/main' timeout-minutes: 40 runs-on: windows-latest @@ -174,6 +169,11 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all + # Keep this aligned with the main Bazel job. The native Windows + # job preserves broad post-merge coverage, but code-mode/V8 tests + # are covered by Cargo CI rather than Bazel for now. + -//codex-rs/code-mode:code-mode-unit-tests + -//codex-rs/v8-poc:v8-poc-unit-tests ) bazel_test_args=( diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bbbb109eff..1eb16166ac 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, @@ -1134,6 +1139,7 @@ mod tests { ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { thread_id: "thread".to_string(), turn_id: "turn".to_string(), + completed_at_ms: 0, item: codex_app_server_protocol::ThreadItem::AgentMessage { id: "item".to_string(), text: text.to_string(), @@ -2007,6 +2013,7 @@ mod tests { codex_app_server_protocol::ItemCompletedNotification { thread_id: "thread".to_string(), turn_id: "turn".to_string(), + completed_at_ms: 0, item: codex_app_server_protocol::ThreadItem::AgentMessage { id: "item".to_string(), text: "hello".to_string(), @@ -2057,6 +2064,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 +2104,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-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 82914f3a6f..8b1d158313 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1932,6 +1932,11 @@ }, "ItemCompletedNotification": { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -1943,6 +1948,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" @@ -2030,6 +2036,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2039,6 +2050,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], @@ -5896,4 +5908,4 @@ } ], "title": "ServerNotification" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6c2ad69e1b..edcca850c2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10109,6 +10109,11 @@ "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/v2/ThreadItem" }, @@ -10120,6 +10125,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" @@ -10213,6 +10219,11 @@ "item": { "$ref": "#/definitions/v2/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -10222,6 +10233,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], @@ -18261,4 +18273,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 150b221adf..e58a8451f2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6762,6 +6762,11 @@ "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -6773,6 +6778,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" @@ -6866,6 +6872,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -6875,6 +6886,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], @@ -16146,4 +16158,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 0831483a32..4ec0e10bc9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1370,6 +1370,11 @@ } }, "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -1381,10 +1386,11 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" ], "title": "ItemCompletedNotification", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 16bfeece14..8684989356 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1373,6 +1373,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1382,9 +1387,10 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], "title": "ItemStartedNotification", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts index 96122204b4..25ced4a075 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle completed. + */ +completedAtMs: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts index 5cf1e7b918..9ec8af09e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle started. + */ +startedAtMs: number, }; diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index 2880ce73f3..609ca83a5d 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -69,6 +69,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id: response.turn_id, item, + completed_at_ms: response.completed_at_ms, }) } EventMsg::CollabAgentSpawnBegin(begin_event) => { @@ -87,6 +88,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabAgentSpawnEnd(end_event) => { @@ -125,6 +127,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabAgentInteractionBegin(begin_event) => { @@ -144,6 +147,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabAgentInteractionEnd(end_event) => { @@ -171,6 +175,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabWaitingBegin(begin_event) => { @@ -194,6 +199,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabWaitingEnd(end_event) => { @@ -229,6 +235,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabCloseBegin(begin_event) => { @@ -247,6 +254,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabCloseEnd(end_event) => { @@ -279,6 +287,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabResumeBegin(begin_event) => { @@ -297,6 +306,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabResumeEnd(end_event) => { @@ -329,6 +339,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::AgentMessageContentDelta(event) => { @@ -378,6 +389,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: item_started_event.item.into(), + started_at_ms: item_started_event.started_at_ms, }) } EventMsg::ItemCompleted(item_completed_event) => { @@ -385,6 +397,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: item_completed_event.item.into(), + completed_at_ms: item_completed_event.completed_at_ms, }) } EventMsg::PatchApplyUpdated(event) => { @@ -400,6 +413,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: build_command_execution_begin_item(&exec_command_begin_event), + started_at_ms: exec_command_begin_event.started_at_ms, }) } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { @@ -428,6 +442,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: build_command_execution_end_item(&exec_command_end_event), + completed_at_ms: exec_command_end_event.completed_at_ms, }) } _ => unreachable!("unsupported item event"), @@ -480,6 +495,7 @@ mod tests { fn collab_resume_begin_maps_to_item_started_resume_agent() { let event = CollabResumeBeginEvent { call_id: "call-1".to_string(), + started_at_ms: 123, sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), receiver_agent_nickname: None, @@ -496,6 +512,7 @@ mod tests { ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: event.started_at_ms, item: ThreadItem::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, @@ -515,6 +532,7 @@ mod tests { fn collab_resume_end_maps_to_item_completed_resume_agent() { let event = CollabResumeEndEvent { call_id: "call-2".to_string(), + completed_at_ms: 456, sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), receiver_agent_nickname: None, @@ -533,6 +551,7 @@ mod tests { ItemCompletedNotification { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), + completed_at_ms: event.completed_at_ms, item: ThreadItem::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 57eeedec34..6228d754d6 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1356,6 +1356,7 @@ mod tests { id: "user-item-id".to_string(), content: Vec::new(), }), + started_at_ms: 0, }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), @@ -1820,6 +1821,7 @@ mod tests { call_id: "exec-1".into(), process_id: Some("pid-1".into()), turn_id: "turn-1".into(), + completed_at_ms: 0, command: vec!["echo".into(), "hello world".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { @@ -1983,6 +1985,7 @@ mod tests { codex_protocol::dynamic_tools::DynamicToolCallRequest { call_id: "dyn-1".into(), turn_id: "turn-1".into(), + started_at_ms: 0, namespace: Some("codex_app".into()), tool: "lookup_ticket".into(), arguments: serde_json::json!({"id":"ABC-123"}), @@ -1991,6 +1994,7 @@ mod tests { EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { call_id: "dyn-1".into(), turn_id: "turn-1".into(), + completed_at_ms: 0, namespace: Some("codex_app".into()), tool: "lookup_ticket".into(), arguments: serde_json::json!({"id":"ABC-123"}), @@ -2046,6 +2050,7 @@ mod tests { call_id: "exec-declined".into(), process_id: Some("pid-2".into()), turn_id: "turn-1".into(), + completed_at_ms: 0, command: vec!["ls".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], @@ -2293,6 +2298,7 @@ mod tests { call_id: "exec-late".into(), process_id: Some("pid-42".into()), turn_id: "turn-a".into(), + completed_at_ms: 0, command: vec!["echo".into(), "done".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { @@ -2384,6 +2390,7 @@ mod tests { call_id: "exec-unknown-turn".into(), process_id: Some("pid-42".into()), turn_id: "turn-missing".into(), + completed_at_ms: 0, command: vec!["echo".into(), "done".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { @@ -2732,6 +2739,7 @@ mod tests { }), EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { call_id: "resume-1".into(), + completed_at_ms: 0, sender_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") .expect("valid sender thread id"), receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") @@ -2788,6 +2796,7 @@ mod tests { }), EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent { call_id: "spawn-1".into(), + completed_at_ms: 0, sender_thread_id, new_thread_id: Some(spawned_thread_id), new_agent_nickname: Some("Scout".into()), @@ -2849,6 +2858,7 @@ mod tests { EventMsg::CollabAgentInteractionBegin( codex_protocol::protocol::CollabAgentInteractionBeginEvent { call_id: "send-1".into(), + started_at_ms: 0, sender_thread_id: sender, receiver_thread_id: receiver, prompt: "new task".into(), @@ -2857,6 +2867,7 @@ mod tests { EventMsg::CollabAgentInteractionEnd( codex_protocol::protocol::CollabAgentInteractionEndEvent { call_id: "send-1".into(), + completed_at_ms: 0, sender_thread_id: sender, receiver_thread_id: receiver, receiver_agent_nickname: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4eb33fa850..053895c2bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3631,8 +3631,9 @@ pub struct ThreadStartParams { #[experimental("thread/start.experimentalRawEvents")] #[serde(default)] pub experimental_raw_events: bool, - /// If true, persist additional rollout EventMsg variants required to - /// reconstruct a richer thread history on resume/fork/read. + /// If true, persist additional EventMsg variants to the rollout file. + /// However, `thread/read`, `thread/resume`, and `thread/fork` still only + /// return the limited form of thread history for scalability reasons. #[experimental("thread/start.persistFullHistory")] #[serde(default)] pub persist_extended_history: bool, @@ -3762,8 +3763,9 @@ pub struct ThreadResumeParams { #[experimental("thread/resume.excludeTurns")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exclude_turns: bool, - /// If true, persist additional rollout EventMsg variants required to - /// reconstruct a richer thread history on subsequent resume/fork/read. + /// If true, persist additional EventMsg variants to the rollout file. + /// However, `thread/read`, `thread/resume`, and `thread/fork` still only + /// return the limited form of thread history for scalability reasons. #[experimental("thread/resume.persistFullHistory")] #[serde(default)] pub persist_extended_history: bool, @@ -3867,8 +3869,9 @@ pub struct ThreadForkParams { #[experimental("thread/fork.excludeTurns")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exclude_turns: bool, - /// If true, persist additional rollout EventMsg variants required to - /// reconstruct a richer thread history on subsequent resume/fork/read. + /// If true, persist additional EventMsg variants to the rollout file. + /// However, `thread/read`, `thread/resume`, and `thread/fork` still only + /// return the limited form of thread history for scalability reasons. #[experimental("thread/fork.persistFullHistory")] #[serde(default)] pub persist_extended_history: bool, @@ -6925,6 +6928,9 @@ pub struct ItemStartedNotification { pub item: ThreadItem, pub thread_id: String, pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle started. + #[ts(type = "number")] + pub started_at_ms: i64, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -6987,6 +6993,9 @@ pub struct ItemCompletedNotification { pub item: ThreadItem, pub thread_id: String, pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle completed. + #[ts(type = "number")] + pub completed_at_ms: i64, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dab47ec3a2..df41d859cd 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -307,8 +307,6 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage. -Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously. - ### Example: List threads (with pagination & filters) `thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of: @@ -403,7 +401,7 @@ Later, after the idle unload timeout: ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the full rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 29fec1b32d..89b5d26579 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2,6 +2,7 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; +use crate::request_processors::build_api_turns_from_rollout_items; use crate::request_processors::read_rollout_items_from_rollout; use crate::request_processors::read_summary_from_rollout; use crate::request_processors::summary_to_thread; @@ -81,7 +82,6 @@ use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WarningNotification; use codex_app_server_protocol::build_item_from_guardian_event; -use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::guardian_auto_approval_review_notification; use codex_app_server_protocol::item_event_to_server_notification; use codex_core::CodexThread; @@ -116,6 +116,8 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; @@ -816,6 +818,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), + started_at_ms: request.started_at_ms, item, }; outgoing @@ -968,6 +971,7 @@ pub(crate) async fn apply_bespoke_event_handling( let started = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + started_at_ms: now_unix_timestamp_ms(), item: item.clone(), }; outgoing @@ -976,6 +980,7 @@ pub(crate) async fn apply_bespoke_event_handling( let completed = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), item, }; outgoing @@ -1025,6 +1030,7 @@ pub(crate) async fn apply_bespoke_event_handling( let started = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + started_at_ms: now_unix_timestamp_ms(), item: item.clone(), }; outgoing @@ -1033,6 +1039,7 @@ pub(crate) async fn apply_bespoke_event_handling( let completed = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), item, }; outgoing @@ -1179,7 +1186,7 @@ pub(crate) async fn apply_bespoke_event_handling( let mut thread = summary_to_thread(summary, &fallback_cwd); match read_rollout_items_from_rollout(rollout_path.as_path()).await { Ok(items) => { - thread.turns = build_turns_from_rollout_items(&items); + thread.turns = build_api_turns_from_rollout_items(&items); thread.status = thread_watch_manager .loaded_status_for_thread(&thread.id) .await; @@ -1368,6 +1375,7 @@ async fn start_command_execution_item( let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id, + started_at_ms: now_unix_timestamp_ms(), item: ThreadItem::CommandExecution { id: item_id, command, @@ -1427,6 +1435,7 @@ async fn complete_command_execution_item( let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id, + completed_at_ms: now_unix_timestamp_ms(), item, }; outgoing @@ -1474,6 +1483,7 @@ pub(crate) async fn maybe_emit_hook_prompt_item_completed( let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: turn_id.to_string(), + completed_at_ms: now_unix_timestamp_ms(), item: ThreadItem::HookPrompt { id: hook_prompt.id, fragments: hook_prompt @@ -2077,6 +2087,13 @@ async fn on_command_execution_request_approval_response( } } +fn now_unix_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; 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 45bc9c2629..f59ea14402 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -169,6 +169,7 @@ use codex_app_server_protocol::ThreadGoalSetParams; use codex_app_server_protocol::ThreadGoalSetResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadGoalUpdatedNotification; +use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; use codex_app_server_protocol::ThreadInjectItemsParams; @@ -233,7 +234,6 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; -use codex_app_server_protocol::build_turns_from_rollout_items; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; @@ -366,8 +366,9 @@ use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; 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; @@ -479,3 +480,13 @@ use self::thread_summary::*; pub(crate) use self::thread_summary::read_rollout_items_from_rollout; pub(crate) use self::thread_summary::read_summary_from_rollout; pub(crate) use self::thread_summary::summary_to_thread; + +pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { + let mut builder = ThreadHistoryBuilder::new(); + for item in items { + if is_persisted_rollout_item(item, EventPersistenceMode::Limited) { + builder.handle_rollout_item(item); + } + } + builder.finish() +} 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_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 9b7b9fd576..9dabdea44f 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -542,17 +542,12 @@ pub(super) async fn handle_pending_thread_resume_request( let request_id = pending.request_id; let connection_id = request_id.connection_id; let mut thread = pending.thread_summary; - if pending.include_turns - && let Err(message) = populate_thread_turns_from_history( + if pending.include_turns { + populate_thread_turns_from_history( &mut thread, &pending.history_items, active_turn.as_ref(), - ) - { - outgoing - .send_error(request_id, internal_error(message)) - .await; - return; + ); } let thread_status = thread_watch_manager @@ -711,13 +706,12 @@ pub(super) fn populate_thread_turns_from_history( thread: &mut Thread, items: &[RolloutItem], active_turn: Option<&Turn>, -) -> std::result::Result<(), String> { - let mut turns = build_turns_from_rollout_items(items); +) { + let mut turns = build_api_turns_from_rollout_items(items); if let Some(active_turn) = active_turn { merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); } thread.turns = turns; - Ok(()) } pub(super) async fn resolve_pending_server_request( 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 495ec08cea..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), @@ -2048,7 +2051,7 @@ impl ThreadRequestProcessor { let (mut thread, history) = thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); if include_turns && let Some(history) = history { - thread.turns = build_turns_from_rollout_items(&history.items); + thread.turns = build_api_turns_from_rollout_items(&history.items); } Ok(Some(thread)) } @@ -2113,7 +2116,7 @@ impl ThreadRequestProcessor { .load_history(/*include_archived*/ true) .await .map_err(|err| thread_read_history_load_error(thread_id, err))?; - thread.turns = build_turns_from_rollout_items(&history.items); + thread.turns = build_api_turns_from_rollout_items(&history.items); } Ok(()) @@ -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 @@ -2662,17 +2665,11 @@ impl ThreadRequestProcessor { } let mut summary_source_thread = source_thread; summary_source_thread.history = None; - let thread_summary = match self - .stored_thread_to_api_thread( - summary_source_thread, - config_snapshot.model_provider_id.as_str(), - /*include_turns*/ false, - ) - .await - { - Ok(thread) => thread, - Err(message) => return Err(internal_error(message)), - }; + let thread_summary = self.stored_thread_to_api_thread( + summary_source_thread, + config_snapshot.model_provider_id.as_str(), + /*include_turns*/ false, + ); let mut config_for_instruction_sources = self.config.as_ref().clone(); config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); let instruction_sources = @@ -2798,12 +2795,12 @@ impl ThreadRequestProcessor { })) } - async fn stored_thread_to_api_thread( + fn stored_thread_to_api_thread( &self, stored_thread: StoredThread, fallback_provider: &str, include_turns: bool, - ) -> std::result::Result { + ) -> Thread { let (mut thread, history) = thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); if include_turns && let Some(history) = history { @@ -2811,9 +2808,9 @@ impl ThreadRequestProcessor { &mut thread, &history.items, /*active_turn*/ None, - )?; + ); } - Ok(thread) + thread } async fn read_stored_thread_for_new_fork( @@ -2921,14 +2918,16 @@ impl ThreadRequestProcessor { &mut thread, &history_items, /*active_turn*/ None, - )?; + ); } self.attach_thread_name(thread_id, &mut thread).await; Ok(thread) } 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); } } @@ -3066,7 +3065,7 @@ impl ThreadRequestProcessor { // Persistent forks materialize their own rollout immediately. Ephemeral forks stay // pathless, so they rebuild their visible history from the copied source history instead. - let mut thread = if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() { + let mut thread = if session_configured.rollout_path.is_some() { let stored_thread = self .read_stored_thread_for_new_fork(thread_id, include_turns) .await?; @@ -3075,13 +3074,6 @@ impl ThreadRequestProcessor { fallback_model_provider.as_str(), include_turns, ) - .await - .map_err(|message| { - internal_error(format!( - "failed to load rollout `{}` for thread {thread_id}: {message}", - fork_rollout_path.display() - )) - })? } else { let config_snapshot = forked_thread.config_snapshot().await; // forked thread names do not inherit the source thread name @@ -3094,8 +3086,7 @@ impl ThreadRequestProcessor { &mut thread, &history_items, /*active_turn*/ None, - ) - .map_err(internal_error)?; + ); } thread }; @@ -3466,16 +3457,6 @@ fn parse_thread_turns_cursor(cursor: &str) -> Result Vec { - let mut turns = build_turns_from_rollout_items(items); - normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn); - turns -} - fn reconstruct_thread_turns_for_turns_list( items: &[RolloutItem], loaded_status: ThreadStatus, @@ -3486,11 +3467,8 @@ fn reconstruct_thread_turns_for_turns_list( || active_turn .as_ref() .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); - let mut turns = reconstruct_thread_turns_from_rollout_items( - items, - loaded_status, - has_live_in_progress_turn, - ); + let mut turns = build_api_turns_from_rollout_items(items); + normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn); if let Some(active_turn) = active_turn { merge_turn_history_with_active_turn(&mut turns, active_turn); } @@ -3710,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/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 4ae9187ea9..975819dc7f 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -1225,14 +1225,14 @@ async fn webrtc_v1_start_posts_offer_returns_sdp_and_joins_sideband() -> Result< "v=offer\r\n", v1_session_create_json(), )?; + + let session_update = harness.sideband_outbound_request(/*request_index*/ 0).await; + assert_v1_session_update(&session_update)?; assert_eq!( harness.realtime_server.single_handshake().uri(), "/v1/realtime?intent=quicksilver&call_id=rtc_e2e" ); - let session_update = harness.sideband_outbound_request(/*request_index*/ 0).await; - assert_v1_session_update(&session_update)?; - let closed = timeout( Duration::from_millis(100), harness 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_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index 4580c1879d..eebc4077df 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -15,6 +15,9 @@ use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; @@ -22,6 +25,8 @@ use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -38,7 +43,8 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] -async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> Result<()> { +async fn thread_shell_command_history_responses_exclude_persisted_command_executions() -> Result<()> +{ let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -126,7 +132,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> let read_id = mcp .send_thread_read_request(ThreadReadParams { - thread_id: thread.id, + thread_id: thread.id.clone(), include_turns: true, }) .await?; @@ -137,22 +143,40 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> .await??; let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); - let ThreadItem::CommandExecution { - source, - status, - aggregated_output, - .. - } = thread.turns[0] - .items - .iter() - .find(|item| matches!(item, ThreadItem::CommandExecution { .. })) - .expect("expected persisted command execution item") - else { - unreachable!("matched command execution item"); - }; - assert_eq!(source, &CommandExecutionSource::UserShell); - assert_eq!(status, &CommandExecutionStatus::Completed); - assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); + assert_no_command_executions(&thread.turns[0].items, "thread/read"); + + let turns_list_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread.id.clone(), + cursor: None, + limit: None, + sort_direction: Some(SortDirection::Asc), + }) + .await?; + let turns_list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = + to_response::(turns_list_resp)?; + assert_eq!(data.len(), 1); + assert_no_command_executions(&data[0].items, "thread/turns/list"); + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + assert_eq!(thread.turns.len(), 1); + assert_no_command_executions(&thread.turns[0].items, "thread/fork"); Ok(()) } @@ -307,23 +331,20 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { .await??; let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); - assert!( - thread.turns[0].items.iter().any(|item| { - matches!( - item, - ThreadItem::CommandExecution { - source: CommandExecutionSource::UserShell, - aggregated_output, - .. - } if aggregated_output.as_deref() == Some(expected_output.as_str()) - ) - }), - "expected active-turn shell command to be persisted on the existing turn" - ); + assert_no_command_executions(&thread.turns[0].items, "thread/read"); Ok(()) } +fn assert_no_command_executions(items: &[ThreadItem], context: &str) { + assert!( + items + .iter() + .all(|item| !matches!(item, ThreadItem::CommandExecution { .. })), + "{context} should always exclude command executions from returned turns" + ); +} + fn current_shell_output_command(text: &str) -> Result<(String, String)> { let command_and_output = match default_user_shell().name() { "powershell" => { 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 7b6e7448d4..c56055ea2d 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", @@ -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/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 915e9ff8bc..8973b35584 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -43,7 +43,7 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { server_provenance: Default::default(), callable_name: tool_name.to_string(), callable_namespace: tool_namespace, - server_instructions: None, + namespace_description: None, tool: Tool { name: tool_name.to_string().into(), title: None, @@ -58,7 +58,6 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { connector_id: None, connector_name: None, plugin_display_names: Vec::new(), - connector_description: None, } } diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index aa84dc6152..196cfa18bd 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -74,12 +74,7 @@ pub fn mcp_permission_prompt_is_auto_approved( permission_profile: &PermissionProfile, context: McpPermissionPromptAutoApproveContext, ) -> bool { - if matches!( - approval_policy, - AskForApproval::OnRequest | AskForApproval::Granular(_) - ) && context.approvals_reviewer == Some(ApprovalsReviewer::AutoReview) - && context.tool_approval_mode == Some(AppToolApproval::Approve) - { + if context.tool_approval_mode == Some(AppToolApproval::Approve) { return true; } diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index d601d10a87..6a73e3a77e 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -74,16 +74,11 @@ fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() { } #[test] -fn mcp_prompt_auto_approval_honors_auto_review_approved_tools() { - assert!(mcp_permission_prompt_is_auto_approved( +fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { + for approval_policy in [ + AskForApproval::UnlessTrusted, + AskForApproval::OnFailure, AskForApproval::OnRequest, - &PermissionProfile::read_only(), - McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), - }, - )); - assert!(mcp_permission_prompt_is_auto_approved( AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, rules: true, @@ -91,34 +86,36 @@ fn mcp_prompt_auto_approval_honors_auto_review_approved_tools() { request_permissions: true, mcp_elicitations: true, }), + AskForApproval::Never, + ] { + assert!(mcp_permission_prompt_is_auto_approved( + approval_policy, + &PermissionProfile::read_only(), + McpPermissionPromptAutoApproveContext { + approvals_reviewer: Some(ApprovalsReviewer::User), + tool_approval_mode: Some(AppToolApproval::Approve), + }, + )); + } + + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), + tool_approval_mode: Some(AppToolApproval::Auto), }, )); +} + +#[test] +fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { assert!(!mcp_permission_prompt_is_auto_approved( AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { approvals_reviewer: Some(ApprovalsReviewer::User), - tool_approval_mode: Some(AppToolApproval::Approve), - }, - )); - assert!(!mcp_permission_prompt_is_auto_approved( - AskForApproval::OnFailure, - &PermissionProfile::read_only(), - McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), - }, - )); - assert!(!mcp_permission_prompt_is_auto_approved( - AskForApproval::UnlessTrusted, - &PermissionProfile::read_only(), - McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), + tool_approval_mode: Some(AppToolApproval::Auto), }, )); } diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 228b7c4036..f06be83b22 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -377,17 +377,24 @@ pub(crate) async fn list_tools_for_client_uncached( tool_def.title = Some(normalized_title); } } + let has_connector_metadata = connector_id.is_some() + || connector_name.is_some() + || connector_description.is_some(); + let namespace_description = if has_connector_metadata { + connector_description + } else { + server_instructions.map(str::to_string) + }; ToolInfo { server_name: server_name.to_owned(), server_provenance, callable_name, callable_namespace, - server_instructions: server_instructions.map(str::to_string), + namespace_description, tool: tool_def, connector_id, connector_name, plugin_display_names: Vec::new(), - connector_description, } }) .collect(); diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 5d11cdc403..b2427b1e2b 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -39,16 +39,16 @@ pub struct ToolInfo { /// Model-visible namespace used for deferred tool loading. #[serde(rename = "tool_namespace", alias = "callable_namespace")] pub callable_namespace: String, - /// Instructions from the MCP server initialize result. - #[serde(default)] - pub server_instructions: Option, + /// Model-visible namespace description. + // Keep the old serialized field name readable for cached ToolInfo values. + #[serde(default, alias = "connector_description")] + pub namespace_description: Option, /// Raw MCP tool definition; `tool.name` is sent back to the MCP server. pub tool: Tool, pub connector_id: Option, pub connector_name: Option, #[serde(default)] pub plugin_display_names: Vec, - pub connector_description: Option, } impl ToolInfo { 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-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/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/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/client.rs b/codex-rs/core/src/client.rs index c60c4507d7..d11fda47dc 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -107,6 +107,7 @@ use tracing::warn; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; +use crate::feedback_tags; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::util::emit_feedback_auth_recovery_tags; use codex_api::map_api_error; @@ -1685,6 +1686,9 @@ where let mut items_added: Vec = Vec::new(); let mut api_stream = api_stream; let upstream_request_id = upstream_request_id.as_deref(); + if let Some(upstream_request_id) = upstream_request_id { + feedback_tags!(last_model_request_id = upstream_request_id); + } loop { let event = tokio::select! { _ = consumer_dropped.cancelled() => { @@ -1721,6 +1725,7 @@ where token_usage, end_turn, }) => { + feedback_tags!(last_model_response_id = &response_id); if let Some(usage) = &token_usage { session_telemetry.sse_event_completed( usage.input_tokens, @@ -1769,6 +1774,9 @@ where extract_response_debug_context_from_api_error(&err); let upstream_request_id = upstream_request_id.or(response_debug_context.request_id.as_deref()); + if let Some(upstream_request_id) = upstream_request_id { + feedback_tags!(last_model_request_id = upstream_request_id); + } let mapped = map_api_error(err); inference_trace_attempt.record_failed( &mapped, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index e56500ba5f..13c5603baa 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -31,14 +31,24 @@ use codex_rollout_trace::replay_bundle; use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use std::collections::VecDeque; use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex; use std::task::Context; use std::task::Poll; use std::time::Duration; use tempfile::TempDir; use tokio::sync::Notify; +use tracing::Event; +use tracing::Subscriber; +use tracing::field::Visit; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context as LayerContext; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); @@ -100,6 +110,42 @@ fn test_session_telemetry() -> SessionTelemetry { ) } +#[derive(Default)] +struct TagCollectorVisitor { + tags: BTreeMap, +} + +impl Visit for TagCollectorVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.tags + .insert(field.name().to_string(), value.to_string()); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.tags + .insert(field.name().to_string(), format!("{value:?}")); + } +} + +#[derive(Clone)] +struct TagCollectorLayer { + tags: Arc>>, +} + +impl Layer for TagCollectorLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { + if event.metadata().target() != "feedback_tags" { + return; + } + let mut visitor = TagCollectorVisitor::default(); + event.record(&mut visitor); + self.tags.lock().unwrap().extend(visitor.tags); + } +} + fn started_inference_attempt(temp: &TempDir) -> anyhow::Result { let writer = Arc::new(TraceWriter::create( temp.path(), @@ -316,6 +362,41 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn response_stream_records_last_model_feedback_ids() { + let tags = Arc::new(Mutex::new(BTreeMap::new())); + let _guard = tracing_subscriber::registry() + .with(TagCollectorLayer { tags: tags.clone() }) + .set_default(); + + let api_stream = futures::stream::iter([ + Ok(ResponseEvent::Created), + Ok(ResponseEvent::Completed { + response_id: "resp-123".to_string(), + token_usage: None, + end_turn: Some(true), + }), + ]); + let (mut stream, _) = super::map_response_events( + Some("req-123".to_string()), + api_stream, + test_session_telemetry(), + InferenceTraceAttempt::disabled(), + ); + + while stream.next().await.is_some() {} + + let tags = tags.lock().unwrap().clone(); + assert_eq!( + tags.get("last_model_request_id").map(String::as_str), + Some("\"req-123\"") + ); + assert_eq!( + tags.get("last_model_response_id").map(String::as_str), + Some("\"resp-123\"") + ); +} + #[tokio::test] async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> { 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/connectors.rs b/codex-rs/core/src/connectors.rs index a8707532e4..23a9f915f7 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -525,7 +525,7 @@ pub(crate) fn accessible_connectors_from_mcp_tools( Some(codex_connectors::accessible::AccessibleConnectorTool { connector_id: connector_id.to_string(), connector_name: tool.connector_name.clone(), - connector_description: tool.connector_description.clone(), + connector_description: tool.namespace_description.clone(), plugin_display_names: tool.plugin_display_names.clone(), }) }); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 11b98a9570..f44e069d55 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -121,11 +121,10 @@ fn codex_app_tool( server_provenance: McpServerProvenance::HostOwnedCodexApps, callable_name: tool_name.to_string(), callable_namespace: tool_namespace, - server_instructions: None, + namespace_description: None, tool: test_tool_definition(tool_name), connector_id: Some(connector_id.to_string()), connector_name: connector_name.map(ToOwned::to_owned), - connector_description: None, plugin_display_names: plugin_names(plugin_display_names), } } @@ -201,11 +200,10 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { server_provenance: Default::default(), callable_name: "echo".to_string(), callable_namespace: "sample".to_string(), - server_instructions: None, + namespace_description: None, tool: test_tool_definition("echo"), connector_id: None, connector_name: None, - connector_description: None, plugin_display_names: plugin_names(&["ignored"]), }, ), @@ -327,7 +325,7 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { server_provenance: McpServerProvenance::HostOwnedCodexApps, callable_name: "calendar_create_event".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), - server_instructions: None, + namespace_description: Some("Plan events".to_string()), tool: Tool { name: "calendar_create_event".to_string().into(), title: None, @@ -341,7 +339,6 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { }, connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), - connector_description: Some("Plan events".to_string()), plugin_display_names: Vec::new(), }, )]); 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/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/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index bf137bb821..4afef87324 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -19,6 +19,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_model_provider::create_model_provider; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; use core_test_support::PathExt; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -2383,7 +2384,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval } #[tokio::test] -async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { +async fn approve_mode_skips_arc_interrupt_for_model() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -2404,7 +2405,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { "why": "high-risk action", }], }))) - .expect(1) + .expect(0) .mount(&server) .await; @@ -2446,16 +2447,11 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ) .await; - assert_eq!( - decision, - Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - "Tool call was cancelled because of safety risks: high-risk action".to_string(), - )) - ); + assert_eq!(decision, None); } #[tokio::test] -async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() { +async fn custom_approve_mode_skips_arc_interrupt_for_model() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -2476,7 +2472,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() { "why": "high-risk action", }], }))) - .expect(1) + .expect(0) .mount(&server) .await; @@ -2518,16 +2514,11 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() { ) .await; - assert_eq!( - decision, - Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - "Tool call was cancelled because of safety risks: high-risk action".to_string(), - )) - ); + assert_eq!(decision, None); } #[tokio::test] -async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { +async fn approve_mode_skips_arc_interrupt_without_annotations() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -2548,7 +2539,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { "why": "high-risk action", }], }))) - .expect(1) + .expect(0) .mount(&server) .await; @@ -2590,12 +2581,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { ) .await; - assert_eq!( - decision, - Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - "Tool call was cancelled because of safety risks: high-risk action".to_string(), - )) - ); + assert_eq!(decision, None); } #[tokio::test] @@ -2677,7 +2663,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { } #[tokio::test] -async fn approve_mode_skips_arc_and_guardian_when_guardian_reviewer_is_enabled() { +async fn approve_mode_skips_arc_and_guardian_in_every_permission_mode() { use wiremock::Mock; use wiremock::ResponseTemplate; use wiremock::matchers::method; @@ -2707,33 +2693,6 @@ async fn approve_mode_skips_arc_and_guardian_when_guardian_reviewer_is_enabled() .mount(&server) .await; - let (mut session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - turn_context - .approval_policy - .set(AskForApproval::OnRequest) - .expect("test setup should allow updating approval policy"); - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - config.model_provider.base_url = Some(format!("{}/v1", server.uri())); - config.approvals_reviewer = ApprovalsReviewer::AutoReview; - let config = Arc::new(config); - let models_manager = models_manager_with_provider( - config.codex_home.to_path_buf(), - Arc::clone(&session.services.auth_manager), - config.model_provider.clone(), - ); - session.services.models_manager = models_manager; - turn_context.config = Arc::clone(&config); - turn_context.provider = create_model_provider( - config.model_provider.clone(), - turn_context.auth_manager.clone(), - ); - - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); let invocation = McpInvocation { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), tool: "dangerous_tool".to_string(), @@ -2751,16 +2710,57 @@ async fn approve_mode_skips_arc_and_guardian_when_guardian_reviewer_is_enabled() openai_file_input_params: None, }; - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-3", - &invocation, - "mcp__test__tool", - Some(&metadata), - AppToolApproval::Approve, - ) - .await; + for approval_policy in [ + AskForApproval::UnlessTrusted, + AskForApproval::OnFailure, + AskForApproval::OnRequest, + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + AskForApproval::Never, + ] { + let (mut session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + turn_context + .approval_policy + .set(approval_policy) + .expect("test setup should allow updating approval policy"); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + config.approvals_reviewer = ApprovalsReviewer::User; + let config = Arc::new(config); + let models_manager = models_manager_with_provider( + config.codex_home.to_path_buf(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + ); + session.services.models_manager = models_manager; + turn_context.config = Arc::clone(&config); + turn_context.provider = create_model_provider( + config.model_provider.clone(), + turn_context.auth_manager.clone(), + ); - assert_eq!(decision, None); + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-3", + &invocation, + "mcp__test__tool", + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!(decision, None); + } } diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index c0553b5647..7924a7b5d7 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -64,7 +64,7 @@ fn make_mcp_tool( }, callable_name: tool_name.to_string(), callable_namespace: tool_namespace, - server_instructions: None, + namespace_description: None, tool: Tool { name: tool_name.to_string().into(), title: None, @@ -79,7 +79,6 @@ fn make_mcp_tool( connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), plugin_display_names: Vec::new(), - connector_description: None, } } 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/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index eff209b621..249b3ae15f 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -196,6 +196,12 @@ struct RealtimeInputTask { event_parser: RealtimeEventParser, } +struct RealtimeInputChannels { + user_text_rx: Receiver, + handoff_output_rx: Receiver, + audio_rx: Receiver, +} + impl RealtimeHandoffState { fn new(output_tx: Sender, session_kind: RealtimeSessionKind) -> Self { Self { @@ -212,7 +218,6 @@ struct ConversationState { audio_tx: Sender, user_text_tx: Sender, session_kind: RealtimeSessionKind, - writer: RealtimeWebsocketWriter, handoff: RealtimeHandoffState, input_task: JoinHandle<()>, fanout_task: Option>, @@ -284,39 +289,6 @@ impl RealtimeConversationManager { RealtimeEventParser::RealtimeV2 => RealtimeSessionKind::V2, }; - let client = RealtimeWebsocketClient::new(api_provider); - let (connection, sdp) = if let Some(sdp) = sdp { - let call = model_client - .create_realtime_call_with_headers( - sdp, - session_config.clone(), - extra_headers.unwrap_or_default(), - ) - .await?; - let connection = client - .connect_webrtc_sideband( - session_config, - &call.call_id, - call.sideband_headers, - default_headers(), - ) - .await - .map_err(map_api_error)?; - (connection, Some(call.sdp)) - } else { - let connection = client - .connect( - session_config, - extra_headers.unwrap_or_default(), - default_headers(), - ) - .await - .map_err(map_api_error)?; - (connection, None) - }; - - let writer = connection.writer(); - let events = connection.events(); let (audio_tx, audio_rx) = async_channel::bounded::(AUDIO_IN_QUEUE_CAPACITY); let (user_text_tx, user_text_rx) = @@ -328,24 +300,62 @@ impl RealtimeConversationManager { let realtime_active = Arc::new(AtomicBool::new(true)); let handoff = RealtimeHandoffState::new(handoff_output_tx, session_kind); - let task = spawn_realtime_input_task(RealtimeInputTask { - writer: writer.clone(), - events, + let input_channels = RealtimeInputChannels { user_text_rx, handoff_output_rx, audio_rx, - events_tx, - handoff_state: handoff.clone(), - session_kind, - event_parser, - }); + }; + + let client = RealtimeWebsocketClient::new(api_provider); + let (task, sdp) = if let Some(sdp) = sdp { + let call = model_client + .create_realtime_call_with_headers( + sdp, + session_config.clone(), + extra_headers.unwrap_or_default(), + ) + .await?; + let task = spawn_webrtc_sideband_input_task(RealtimeWebrtcSidebandInputTask { + client, + session_config, + call_id: call.call_id, + sideband_headers: call.sideband_headers, + input_channels, + events_tx, + handoff_state: handoff.clone(), + session_kind, + event_parser, + realtime_active: Arc::clone(&realtime_active), + }); + (task, Some(call.sdp)) + } else { + let connection = client + .connect( + session_config, + extra_headers.unwrap_or_default(), + default_headers(), + ) + .await + .map_err(map_api_error)?; + let task = spawn_realtime_input_task(RealtimeInputTask { + writer: connection.writer(), + events: connection.events(), + user_text_rx: input_channels.user_text_rx, + handoff_output_rx: input_channels.handoff_output_rx, + audio_rx: input_channels.audio_rx, + events_tx, + handoff_state: handoff.clone(), + session_kind, + event_parser, + }); + (task, None) + }; let mut guard = self.state.lock().await; *guard = Some(ConversationState { audio_tx, user_text_tx, session_kind, - writer, handoff, input_task: task, fanout_task: None, @@ -1004,6 +1014,83 @@ pub(crate) async fn handle_close(sess: &Arc, sub_id: String) { } fn spawn_realtime_input_task(input: RealtimeInputTask) -> JoinHandle<()> { + tokio::spawn(run_realtime_input_task(input)) +} + +struct RealtimeWebrtcSidebandInputTask { + client: RealtimeWebsocketClient, + session_config: RealtimeSessionConfig, + call_id: String, + sideband_headers: HeaderMap, + input_channels: RealtimeInputChannels, + events_tx: Sender, + handoff_state: RealtimeHandoffState, + session_kind: RealtimeSessionKind, + event_parser: RealtimeEventParser, + realtime_active: Arc, +} + +fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> JoinHandle<()> { + let RealtimeWebrtcSidebandInputTask { + client, + session_config, + call_id, + sideband_headers, + input_channels, + events_tx, + handoff_state, + session_kind, + event_parser, + realtime_active, + } = input; + + tokio::spawn(async move { + if !realtime_active.load(Ordering::Relaxed) { + return; + } + + let connection = match client + .connect_webrtc_sideband( + session_config, + &call_id, + sideband_headers, + default_headers(), + ) + .await + { + Ok(connection) => connection, + Err(err) => { + if realtime_active.load(Ordering::Relaxed) { + let mapped_error = map_api_error(err); + warn!("failed to connect realtime sideband: {mapped_error}"); + let _ = events_tx + .send(RealtimeEvent::Error(mapped_error.to_string())) + .await; + } + return; + } + }; + + if !realtime_active.load(Ordering::Relaxed) { + return; + } + + run_realtime_input_task(RealtimeInputTask { + writer: connection.writer(), + events: connection.events(), + user_text_rx: input_channels.user_text_rx, + handoff_output_rx: input_channels.handoff_output_rx, + audio_rx: input_channels.audio_rx, + events_tx, + handoff_state, + session_kind, + event_parser, + }) + .await; + }) +} + +async fn run_realtime_input_task(input: RealtimeInputTask) { let RealtimeInputTask { writer, events, @@ -1016,57 +1103,55 @@ fn spawn_realtime_input_task(input: RealtimeInputTask) -> JoinHandle<()> { event_parser, } = input; - tokio::spawn(async move { - let mut output_audio_state: Option = None; - let mut response_create_queue = RealtimeResponseCreateQueue::default(); + let mut output_audio_state: Option = None; + let mut response_create_queue = RealtimeResponseCreateQueue::default(); - loop { - let result = tokio::select! { - // Text typed by the user that should be sent into realtime. - user_text = user_text_rx.recv() => { - handle_user_text_input( - user_text, - &writer, - &events_tx, - ) - .await - } - // Background agent progress or final output that should be sent back to realtime. - background_agent_output = handoff_output_rx.recv() => { - handle_handoff_output( - background_agent_output, - &writer, - &events_tx, - &handoff_state, - event_parser, - &mut response_create_queue, - ) - .await - } - // Events received from the realtime server. - realtime_event = events.next_event() => { - handle_realtime_server_event( - realtime_event, - &writer, - &events_tx, - &handoff_state, - session_kind, - &mut output_audio_state, - &mut response_create_queue, - ) + loop { + let result = tokio::select! { + // Text typed by the user that should be sent into realtime. + user_text = user_text_rx.recv() => { + handle_user_text_input( + user_text, + &writer, + &events_tx, + ) .await - } - // Audio frames captured from the user microphone. - user_audio_frame = audio_rx.recv() => { - handle_user_audio_input(user_audio_frame, &writer, &events_tx) - .await - } - }; - if result.is_err() { - break; } + // Background agent progress or final output that should be sent back to realtime. + background_agent_output = handoff_output_rx.recv() => { + handle_handoff_output( + background_agent_output, + &writer, + &events_tx, + &handoff_state, + event_parser, + &mut response_create_queue, + ) + .await + } + // Events received from the realtime server. + realtime_event = events.next_event() => { + handle_realtime_server_event( + realtime_event, + &writer, + &events_tx, + &handoff_state, + session_kind, + &mut output_audio_state, + &mut response_create_queue, + ) + .await + } + // Audio frames captured from the user microphone. + user_audio_frame = audio_rx.recv() => { + handle_user_audio_input(user_audio_frame, &writer, &events_tx) + .await + } + }; + if result.is_err() { + break; } - }) + } } async fn handle_user_text_input( 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..46d942f710 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -41,6 +41,7 @@ use crate::session_prefix::format_subagent_notification_message; use crate::skills::SkillRenderSideEffects; use crate::skills_load_input_from_config; use crate::turn_metadata::TurnMetadataState; +use crate::turn_timing::now_unix_timestamp_ms; use async_channel::Receiver; use async_channel::Sender; use chrono::Local; @@ -355,6 +356,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; @@ -555,7 +557,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 } @@ -1297,6 +1307,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(), ); } @@ -1642,6 +1653,7 @@ impl Session { thread_id: self.conversation_id, turn_id: turn_context.sub_id.clone(), item: item.clone(), + started_at_ms: now_unix_timestamp_ms(), }), ) .await; @@ -1659,6 +1671,7 @@ impl Session { thread_id: self.conversation_id, turn_id: turn_context.sub_id.clone(), item, + completed_at_ms: now_unix_timestamp_ms(), }), ) .await; 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..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 { @@ -968,7 +969,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..fe38cd7f64 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] @@ -3490,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(), ) @@ -3637,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()), @@ -3808,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(), ) @@ -4418,15 +4423,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 +4455,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 +4480,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 +4513,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 +4542,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); } @@ -4986,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); @@ -5115,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)), @@ -5213,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 @@ -5226,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 @@ -7050,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 { @@ -7304,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 @@ -7311,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 { @@ -7411,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 { @@ -7473,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(), @@ -8137,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; @@ -8199,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; @@ -8259,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/session/turn.rs b/codex-rs/core/src/session/turn.rs index 27f9d26b82..ce035b2cea 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1885,6 +1885,7 @@ async fn try_run_sampling_request( Box, )> = None; let mut should_emit_turn_diff = false; + let reasoning_effort = turn_context.effective_reasoning_effort_for_tracing(); let plan_mode = turn_context.collaboration_mode.mode == ModeKind::Plan; let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); @@ -1896,6 +1897,7 @@ async fn try_run_sampling_request( otel.name = field::Empty, tool_name = field::Empty, from = field::Empty, + codex.request.reasoning_effort = %reasoning_effort, gen_ai.usage.input_tokens = field::Empty, gen_ai.usage.cache_read.input_tokens = field::Empty, gen_ai.usage.output_tokens = field::Empty, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a87d2ab178..faa8d693a9 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(); @@ -121,6 +118,20 @@ impl TurnContext { ) } + pub(crate) fn effective_reasoning_effort_for_tracing(&self) -> String { + if self.model_info.supports_reasoning_summaries { + match self + .reasoning_effort + .or(self.model_info.default_reasoning_level) + { + Some(effort) => effort.to_string(), + None => "default".to_string(), + } + } else { + "default".to_string() + } + } + pub(crate) fn model_context_window(&self) -> Option { let effective_context_window_percent = self.model_info.effective_context_window_percent; self.model_info @@ -198,7 +209,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 +446,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 +487,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 +660,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 +672,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 +781,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/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/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 83de03fc1a..bb7a79f58e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -366,12 +366,14 @@ impl Session { let task_cancellation_token = cancellation_token.child_token(); // Task-owned turn spans keep a core-owned span open for the // full task lifecycle after the submission dispatch span ends. + let reasoning_effort = turn_context.effective_reasoning_effort_for_tracing(); let task_span = info_span!( "turn", otel.name = span_name, thread.id = %self.conversation_id, turn.id = %turn_context.sub_id, model = %turn_context.model_info.slug, + codex.turn.reasoning_effort = %reasoning_effort, codex.turn.token_usage.input_tokens = field::Empty, codex.turn.token_usage.cached_input_tokens = field::Empty, codex.turn.token_usage.non_cached_input_tokens = field::Empty, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 23cd076404..683856b90e 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -21,6 +21,7 @@ use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; +use crate::turn_timing::now_unix_timestamp_ms; use crate::user_shell_command::user_shell_command_record_item; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; @@ -164,6 +165,7 @@ pub(crate) async fn execute_user_shell_command( call_id: call_id.clone(), process_id: None, turn_id: turn_context.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), @@ -236,6 +238,7 @@ pub(crate) async fn execute_user_shell_command( call_id, process_id: None, turn_id: turn_context.sub_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), @@ -260,6 +263,7 @@ pub(crate) async fn execute_user_shell_command( call_id: call_id.clone(), process_id: None, turn_id: turn_context.sub_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), @@ -304,6 +308,7 @@ pub(crate) async fn execute_user_shell_command( call_id, process_id: None, turn_id: turn_context.sub_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), command: display_command, cwd, parsed_cmd, 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 eb7419076d..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() @@ -1100,7 +1131,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..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"); @@ -444,9 +446,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 +473,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] @@ -486,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 @@ -539,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 @@ -602,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; @@ -807,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 @@ -909,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 @@ -1000,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 @@ -1136,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/events.rs b/codex-rs/core/src/tools/events.rs index 6469a4984e..f97e51fd8a 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -3,6 +3,7 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::sandboxing::ToolError; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; @@ -77,6 +78,7 @@ pub(crate) async fn emit_exec_command_begin( call_id: ctx.call_id.to_string(), process_id: process_id.map(str::to_owned), turn_id: ctx.turn.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), command: command.to_vec(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.to_vec(), @@ -472,6 +474,7 @@ async fn emit_exec_end( call_id: ctx.call_id.to_string(), process_id: exec_input.process_id.map(str::to_owned), turn_id: ctx.turn.sub_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), command: exec_input.command.to_vec(), cwd: exec_input.cwd.clone(), parsed_cmd: exec_input.parsed_cmd.to_vec(), 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/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index b7e07090dc..eab1f0f808 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::dynamic_tools::DynamicToolCallRequest; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::FunctionCallOutputContentItem; @@ -102,9 +103,11 @@ async fn request_dynamic_tool( } let started_at = Instant::now(); + let started_at_ms = now_unix_timestamp_ms(); let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { call_id: call_id.clone(), turn_id: turn_id.clone(), + started_at_ms, namespace: namespace.clone(), tool: tool.clone(), arguments: arguments.clone(), @@ -116,6 +119,7 @@ async fn request_dynamic_tool( Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { call_id, turn_id, + completed_at_ms: now_unix_timestamp_ms(), namespace, tool, arguments, @@ -127,6 +131,7 @@ async fn request_dynamic_tool( None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { call_id, turn_id, + completed_at_ms: now_unix_timestamp_ms(), namespace, tool, arguments, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index 8c00b0a13c..0b308bb09e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -1,4 +1,5 @@ use super::*; +use crate::turn_timing::now_unix_timestamp_ms; pub(crate) struct Handler; @@ -34,6 +35,7 @@ impl ToolHandler for Handler { &turn, CollabCloseBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, } @@ -54,6 +56,7 @@ impl ToolHandler for Handler { &turn, CollabCloseEndEvent { call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), @@ -75,6 +78,7 @@ impl ToolHandler for Handler { &turn, CollabCloseEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 2d4f2c3f47..59a5038934 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -1,5 +1,6 @@ use super::*; use crate::agent::next_thread_spawn_depth; +use crate::turn_timing::now_unix_timestamp_ms; use std::sync::Arc; pub(crate) struct Handler; @@ -46,6 +47,7 @@ impl ToolHandler for Handler { &turn, CollabResumeBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), @@ -101,6 +103,7 @@ impl ToolHandler for Handler { &turn, CollabResumeEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 4ae3240cb3..1feb21b839 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,5 +1,6 @@ use super::*; use crate::agent::control::render_input_preview; +use crate::turn_timing::now_unix_timestamp_ms; pub(crate) struct Handler; @@ -45,6 +46,7 @@ impl ToolHandler for Handler { &turn, CollabAgentInteractionBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id, prompt: prompt.clone(), @@ -67,6 +69,7 @@ impl ToolHandler for Handler { &turn, CollabAgentInteractionEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname, 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..bc5dcd6929 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,7 @@ 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; +use crate::turn_timing::now_unix_timestamp_ms; pub(crate) struct Handler; @@ -51,6 +51,7 @@ impl ToolHandler for Handler { &turn, CollabAgentSpawnBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), @@ -83,29 +84,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 { @@ -154,6 +148,7 @@ impl ToolHandler for Handler { &turn, CollabAgentSpawnEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, new_thread_id, new_agent_nickname, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 77fa5f83a2..49b85dbfb3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -1,5 +1,6 @@ use super::*; use crate::agent::status::is_final; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use futures::FutureExt; use futures::StreamExt; @@ -73,6 +74,7 @@ impl ToolHandler for Handler { .send_event( &turn, CollabWaitingBeginEvent { + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_ids: receiver_thread_ids.clone(), receiver_agents: receiver_agents.clone(), @@ -105,6 +107,7 @@ impl ToolHandler for Handler { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), agent_statuses: build_wait_agent_statuses( &statuses, &receiver_agents, @@ -173,6 +176,7 @@ impl ToolHandler for Handler { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id, + completed_at_ms: now_unix_timestamp_ms(), agent_statuses, statuses: statuses_by_id, } 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/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 8074f7fe04..c0a1bcbc53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -1,4 +1,5 @@ use super::*; +use crate::turn_timing::now_unix_timestamp_ms; pub(crate) struct Handler; @@ -43,6 +44,7 @@ impl ToolHandler for Handler { &turn, CollabCloseBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, } @@ -63,6 +65,7 @@ impl ToolHandler for Handler { &turn, CollabCloseEndEvent { call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname.clone(), @@ -87,6 +90,7 @@ impl ToolHandler for Handler { &turn, CollabCloseEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, receiver_agent_nickname: receiver_agent.agent_nickname, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index a42cde8f62..12e443b814 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -5,6 +5,7 @@ use super::*; use crate::tools::context::FunctionToolOutput; +use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -97,6 +98,7 @@ async fn handle_message_submission( &turn, CollabAgentInteractionBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id, prompt: prompt.clone(), @@ -132,6 +134,7 @@ async fn handle_message_submission( &turn, CollabAgentInteractionEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_id, receiver_agent_nickname: receiver_agent.agent_nickname, 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..184ea36c51 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,7 @@ 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 crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; @@ -50,6 +50,7 @@ impl ToolHandler for Handler { &turn, CollabAgentSpawnBeginEvent { call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), @@ -118,12 +119,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 @@ -174,6 +170,7 @@ impl ToolHandler for Handler { &turn, CollabAgentSpawnEndEvent { call_id, + completed_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, new_thread_id, new_agent_nickname, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 778c57be21..b86e237f53 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -1,4 +1,5 @@ use super::*; +use crate::turn_timing::now_unix_timestamp_ms; use std::collections::HashMap; use std::time::Duration; use tokio::time::Instant; @@ -48,6 +49,7 @@ impl ToolHandler for Handler { .send_event( &turn, CollabWaitingBeginEvent { + started_at_ms: now_unix_timestamp_ms(), sender_thread_id: session.conversation_id, receiver_thread_ids: Vec::new(), receiver_agents: Vec::new(), @@ -71,6 +73,7 @@ impl ToolHandler for Handler { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id, + completed_at_ms: now_unix_timestamp_ms(), agent_statuses: Vec::new(), statuses: HashMap::new(), } 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/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 8ba05cbb99..f5ff0b7f2a 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -393,7 +393,7 @@ mod tests { server_provenance: Default::default(), callable_name: tool_name.to_string(), callable_namespace: format!("mcp__{server_name}__"), - server_instructions: None, + namespace_description: None, tool: Tool { name: tool_name.to_string().into(), title: None, @@ -412,7 +412,6 @@ mod tests { connector_id: None, connector_name: None, plugin_display_names: Vec::new(), - connector_description: None, } } 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/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 479dcc382f..53cb34f7a4 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -57,10 +57,7 @@ fn map_mcp_tools_for_plan(mcp_tools: &HashMap) -> McpToolPlanI tool.callable_namespace.clone(), ToolNamespace { name: tool.callable_namespace.clone(), - description: tool - .connector_description - .clone() - .or_else(|| tool.server_instructions.clone()), + description: tool.namespace_description.clone(), }, ) }) @@ -118,7 +115,7 @@ pub(crate) fn build_specs_with_discoverable_tools( name: tool.canonical_tool_name(), server_name: tool.server_name.as_str(), connector_name: tool.connector_name.as_deref(), - connector_description: tool.connector_description.as_deref(), + description: tool.namespace_description.as_deref(), }) .collect::>() }); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 717829713e..7243188450 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -63,12 +63,11 @@ fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo { server_provenance: Default::default(), callable_name: tool.name.to_string(), callable_namespace: "mcp__test_server__".to_string(), - server_instructions: None, + namespace_description: None, tool, connector_id: None, connector_name: None, plugin_display_names: Vec::new(), - connector_description: None, } } @@ -83,12 +82,11 @@ fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) server_provenance: Default::default(), callable_name, callable_namespace, - server_instructions: None, + namespace_description: None, tool, connector_id: None, connector_name: None, plugin_display_names: Vec::new(), - connector_description: None, } } @@ -901,7 +899,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio server_provenance: codex_config::McpServerProvenance::HostOwnedCodexApps, callable_name: "_create_event".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), - server_instructions: None, + namespace_description: None, tool: mcp_tool( "calendar_create_event", "Create calendar event", @@ -910,7 +908,6 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), - connector_description: None, }, )])), &[], @@ -954,7 +951,7 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() { server_provenance: codex_config::McpServerProvenance::HostOwnedCodexApps, callable_name: "_create_event".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), - server_instructions: None, + namespace_description: None, tool: mcp_tool( "calendar-create-event", "Create calendar event", @@ -962,7 +959,6 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() { ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), - connector_description: None, plugin_display_names: Vec::new(), }, ), @@ -973,7 +969,7 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() { server_provenance: codex_config::McpServerProvenance::HostOwnedCodexApps, callable_name: "_list_events".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), - server_instructions: None, + namespace_description: None, tool: mcp_tool( "calendar-list-events", "List calendar events", @@ -981,7 +977,6 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() { ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), - connector_description: None, plugin_display_names: Vec::new(), }, ), @@ -992,11 +987,10 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() { server_provenance: Default::default(), callable_name: "echo".to_string(), callable_namespace: "mcp__rmcp__".to_string(), - server_instructions: None, + namespace_description: None, tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), connector_id: None, connector_name: None, - connector_description: None, plugin_display_names: Vec::new(), }, ), diff --git a/codex-rs/core/src/tools/tool_search_entry.rs b/codex-rs/core/src/tools/tool_search_entry.rs index 5d65d81461..06b7efcd06 100644 --- a/codex-rs/core/src/tools/tool_search_entry.rs +++ b/codex-rs/core/src/tools/tool_search_entry.rs @@ -80,7 +80,7 @@ fn mcp_tool_search_entry(info: &ToolInfo) -> Result String { parts.push(connector_name.to_string()); } - if let Some(connector_description) = info.connector_description.as_deref() - && !connector_description.trim().is_empty() + if let Some(description) = info.namespace_description.as_deref() + && !description.trim().is_empty() { - parts.push(connector_description.to_string()); + parts.push(description.to_string()); } parts.extend( diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 54911dcd13..74c3c59d80 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -107,7 +107,7 @@ fn now_unix_timestamp_secs() -> i64 { now_unix_timestamp_ms() / 1000 } -fn now_unix_timestamp_ms() -> i64 { +pub(crate) fn now_unix_timestamp_ms() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); 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/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index c89e6a5188..d2ef9ddc60 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -10,6 +10,7 @@ use tempfile::TempDir; use codex_config::CloudRequirementsLoader; use codex_config::ConfigRequirementsToml; +use codex_config::LoaderOverrides; use codex_config::NetworkRequirementsToml; use codex_core::CodexThread; use codex_core::config::Config; @@ -181,6 +182,7 @@ pub async fn load_default_config_for_test_with_cloud_requirements( cloud_requirements: CloudRequirementsLoader, ) -> Config { ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(default_test_overrides()) .cloud_requirements(cloud_requirements) 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/items.rs b/codex-rs/core/tests/suite/items.rs index 2e60823c0c..9530675acd 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -303,6 +303,15 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }) .await?; + let started = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::WebSearch(item), + started_at_ms, + .. + }) => Some((item.clone(), *started_at_ms)), + _ => None, + }) + .await; let begin = wait_for_event_match(&codex, |ev| match ev { EventMsg::WebSearchBegin(event) => Some(event.clone()), _ => None, @@ -311,16 +320,20 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { let completed = wait_for_event_match(&codex, |ev| match ev { EventMsg::ItemCompleted(ItemCompletedEvent { item: TurnItem::WebSearch(item), + completed_at_ms, .. - }) => Some(item.clone()), + }) => Some((item.clone(), *completed_at_ms)), _ => None, }) .await; assert_eq!(begin.call_id, "web-search-1"); - assert_eq!(completed.id, begin.call_id); + assert_eq!(started.0.id, begin.call_id); + assert!(started.1 > 0); + assert_eq!(completed.0.id, begin.call_id); + assert!(completed.1 > 0); assert_eq!( - completed.action, + completed.0.action, WebSearchAction::Search { query: Some("weather seattle".to_string()), queries: None, @@ -369,11 +382,29 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { }) .await?; + let started = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::ImageGeneration(item), + started_at_ms, + .. + }) => Some((item.clone(), *started_at_ms)), + _ => None, + }) + .await; let begin = wait_for_event_match(&codex, |ev| match ev { EventMsg::ImageGenerationBegin(event) => Some(event.clone()), _ => None, }) .await; + let completed = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::ImageGeneration(item), + completed_at_ms, + .. + }) => Some((item.clone(), *completed_at_ms)), + _ => None, + }) + .await; let end = wait_for_event_match(&codex, |ev| match ev { EventMsg::ImageGenerationEnd(event) => Some(event.clone()), _ => None, @@ -381,6 +412,10 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { .await; assert_eq!(begin.call_id, call_id); + assert_eq!(started.0.id, call_id); + assert!(started.1 > 0); + assert_eq!(completed.0.id, call_id); + assert!(completed.1 > 0); assert_eq!(end.call_id, call_id); assert_eq!(end.status, "completed"); assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string())); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2f9478f3db..ad3280ebf0 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_test_binary_support::TestBinaryDispatchGuard; use codex_test_binary_support::TestBinaryDispatchMode; @@ -16,6 +17,9 @@ pub static CODEX_ALIASES_TEMP_DIR: Option = { if argv1 == Some(CODEX_CORE_APPLY_PATCH_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } + if argv1 == Some(CODEX_FS_HELPER_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if exe_name == CODEX_LINUX_SANDBOX_ARG0 { return TestBinaryDispatchMode::DispatchArg0Only; } diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index deeffcd855..5539599b24 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,6 +1,7 @@ use codex_core::config::Constrained; use codex_features::Feature; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -595,8 +596,9 @@ async fn turn_and_completed_response_spans_record_token_usage() { ) .await; - let TestCodex { codex, .. } = test_codex() + let test = test_codex() .with_config(|config| { + config.model_reasoning_effort = Some(ReasoningEffort::High); config .features .disable(Feature::GhostCommit) @@ -606,6 +608,8 @@ async fn turn_and_completed_response_spans_record_token_usage() { .await .unwrap(); + let TestCodex { codex, .. } = test; + codex .submit(Op::UserInput { environments: None, @@ -625,7 +629,9 @@ async fn turn_and_completed_response_spans_record_token_usage() { assert!( logs.lines().any(|line| { - line.contains("handle_responses{otel.name=\"completed\"") + line.contains("handle_responses{") + && line.contains("otel.name=\"completed\"") + && line.contains("codex.request.reasoning_effort=high") && line.contains("gen_ai.usage.input_tokens=3") && line.contains("gen_ai.usage.cache_read.input_tokens=1") && line.contains("gen_ai.usage.output_tokens=5") @@ -637,6 +643,7 @@ async fn turn_and_completed_response_spans_record_token_usage() { assert!( logs.lines().any(|line| { line.contains("turn{otel.name=\"session_task.turn\"") + && line.contains("codex.turn.reasoning_effort=high") && line.contains("codex.turn.token_usage.input_tokens=3") && line.contains("codex.turn.token_usage.cached_input_tokens=1") && line.contains("codex.turn.token_usage.non_cached_input_tokens=2") @@ -708,13 +715,18 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { let logs = String::from_utf8(buffer.lock().unwrap().clone()).unwrap(); assert!( - logs.contains("handle_responses{otel.name=\"function_call\"") - && logs.contains("tool_name=\"nonexistent\"") - && logs.contains("from=\"output_item_done\""), + logs.lines().any(|line| { + line.contains("handle_responses{") + && line.contains("otel.name=\"function_call\"") + && line.contains("tool_name=\"nonexistent\"") + && line.contains("from=\"output_item_done\"") + }), "missing handle_responses span with function call metadata\nlogs:\n{logs}" ); assert!( - logs.contains("handle_responses{otel.name=\"completed\""), + logs.lines().any(|line| { + line.contains("handle_responses{") && line.contains("otel.name=\"completed\"") + }), "missing handle_responses span for completion\nlogs:\n{logs}" ); } @@ -766,7 +778,9 @@ async fn record_responses_sets_span_fields_for_response_events() { .await; let TestCodex { codex, .. } = test_codex() + .with_model("gpt-5.4") .with_config(|config| { + config.model_reasoning_effort = Some(ReasoningEffort::High); config .features .disable(Feature::GhostCommit) @@ -806,22 +820,24 @@ async fn record_responses_sets_span_fields_for_response_events() { ]; for (name, from, tool_name) in expected { + let otel_name = format!("otel.name=\"{name}\""); + let from_field = from.map(|from| format!("from=\"{from}\"")); + let tool_name_field = tool_name.map(|tool_name| format!("tool_name=\"{tool_name}\"")); + assert!( - logs.contains(&format!("handle_responses{{otel.name=\"{name}\"")), - "missing otel.name={name}\nlogs:\n{logs}" + logs.lines().any(|line| { + line.contains("handle_responses{") + && line.contains(&otel_name) + && line.contains("codex.request.reasoning_effort=high") + && from_field + .as_ref() + .is_none_or(|from_field| line.contains(from_field)) + && tool_name_field + .as_ref() + .is_none_or(|tool_name_field| line.contains(tool_name_field)) + }), + "missing span fields for {name}\nlogs:\n{logs}" ); - if let Some(from) = from { - assert!( - logs.contains(&format!("from=\"{from}\"")), - "missing from={from} for {name}\nlogs:\n{logs}" - ); - } - if let Some(tool_name) = tool_name { - assert!( - logs.contains(&format!("tool_name=\"{tool_name}\"")), - "missing tool_name={tool_name} for {name}\nlogs:\n{logs}" - ); - } } } 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/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 96aa979f9a..ff273a77c4 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -120,6 +120,21 @@ fn websocket_request_instructions( .map(str::to_owned) } +async fn wait_for_websocket_request( + server: &core_test_support::responses::WebSocketTestServer, + connection_index: usize, + request_index: usize, +) -> Result { + timeout( + Duration::from_secs(2), + server.wait_for_request(connection_index, request_index), + ) + .await + .with_context(|| { + format!("timed out waiting for websocket request {connection_index}/{request_index}") + }) +} + fn expected_realtime_backend_prompt() -> String { REALTIME_BACKEND_PROMPT .trim_end() @@ -456,6 +471,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + let sideband_accept_delay = Duration::from_millis(1000); let capture = RealtimeCallRequestCapture::new(); Mock::given(method("POST")) .and(path_regex(".*/realtime/calls$")) @@ -468,12 +484,15 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { .mount(&server) .await; let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { - requests: vec![vec![json!({ - "type": "session.updated", - "session": { "id": "sess_webrtc", "instructions": "backend prompt" } - })]], + requests: vec![ + vec![json!({ + "type": "session.updated", + "session": { "id": "sess_webrtc", "instructions": "backend prompt" } + })], + vec![], + ], response_headers: Vec::new(), - accept_delay: None, + accept_delay: Some(sideband_accept_delay), close_after_requests: false, }]) .await; @@ -510,6 +529,16 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { .await .unwrap_or_else(|err: ErrorEvent| panic!("conversation call create failed: {err:?}")); assert_eq!(created.sdp, "v=answer\r\n"); + assert!( + realtime_server.handshakes().is_empty(), + "SDP should be emitted before the delayed sideband websocket joins" + ); + + test.codex + .submit(Op::RealtimeConversationText(ConversationTextParams { + text: "queued before sideband".to_string(), + })) + .await?; let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { @@ -566,9 +595,12 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { // Phase 3: the server joins that same call over the direct sideband WebSocket, sends the // ordinary session.update, and keeps the conversation alive until the client closes it. - let session_update = realtime_server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) - .await; + let session_update = wait_for_websocket_request( + &realtime_server, + /*connection_index*/ 0, + /*request_index*/ 0, + ) + .await?; assert_eq!( session_update.body_json()["type"].as_str(), Some("session.update") @@ -578,6 +610,16 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { .context("session.update should include instructions")? .contains("startup context") ); + let queued_text = wait_for_websocket_request( + &realtime_server, + /*connection_index*/ 0, + /*request_index*/ 1, + ) + .await?; + assert_eq!( + websocket_request_text(&queued_text).as_deref(), + Some("queued before sideband") + ); let handshake = realtime_server.single_handshake(); assert_eq!( handshake.uri(), @@ -603,6 +645,176 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + Mock::given(method("POST")) + .and(path_regex(".*/realtime/calls$")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/realtime/calls/calls/rtc_close_pending") + .set_body_string("v=answer\r\n"), + ) + .mount(&server) + .await; + let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![vec![]], + response_headers: Vec::new(), + accept_delay: Some(Duration::from_millis(500)), + close_after_requests: false, + }]) + .await; + + let realtime_ws_base_url = realtime_server.uri().to_string(); + let mut builder = test_codex().with_config(move |config| { + config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string()); + config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); + config.experimental_realtime_ws_startup_context = Some(String::new()); + config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url); + config.realtime.version = RealtimeWsVersion::V1; + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + voice: None, + })) + .await?; + + let sdp = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationSdp(created) => Some(created.sdp.clone()), + _ => None, + }) + .await; + assert_eq!(sdp, "v=answer\r\n"); + assert!( + realtime_server.handshakes().is_empty(), + "sideband websocket should still be pending when SDP is emitted" + ); + + test.codex.submit(Op::RealtimeConversationClose).await?; + let closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + assert_eq!(closed.reason.as_deref(), Some("requested")); + + let stale_event = timeout(Duration::from_millis(700), async { + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(message), + }) => Some(format!("stale realtime error: {message}")), + EventMsg::RealtimeConversationClosed(closed) => { + Some(format!("stale close event: {:?}", closed.reason)) + } + _ => None, + }) + .await + }) + .await; + assert!( + stale_event.is_err(), + "pending sideband task leaked after close: {:?}", + stale_event.ok() + ); + assert!( + realtime_server.handshakes().is_empty(), + "pending sideband task should abort before websocket handshake completes" + ); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + Mock::given(method("POST")) + .and(path_regex(".*/realtime/calls$")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/realtime/calls/calls/rtc_sideband_failure") + .set_body_string("v=answer\r\n"), + ) + .mount(&server) + .await; + let mut builder = test_codex().with_config(|config| { + config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string()); + config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); + config.experimental_realtime_ws_startup_context = Some(String::new()); + config.experimental_realtime_ws_base_url = Some("http://127.0.0.1:1".to_string()); + config.realtime.version = RealtimeWsVersion::V1; + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + output_modality: RealtimeOutputModality::Audio, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: Some(ConversationStartTransport::Webrtc { + sdp: "v=offer\r\n".to_string(), + }), + voice: None, + })) + .await?; + + let started = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationStarted(started) => Some(started.clone()), + _ => None, + }) + .await; + assert!(started.realtime_session_id.is_some()); + + let sdp = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationSdp(created) => Some(created.sdp.clone()), + _ => None, + }) + .await; + assert_eq!(sdp, "v=answer\r\n"); + + let err = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(message), + }) => Some(message.clone()), + _ => None, + }) + .await; + assert!(!err.is_empty()); + + let closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + assert_eq!(closed.reason.as_deref(), Some("error")); + + test.codex + .submit(Op::RealtimeConversationText(ConversationTextParams { + text: "after sideband failure".to_string(), + })) + .await?; + let err = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::Error(err) => Some(err.clone()), + _ => None, + }) + .await; + assert_eq!(err.message, "conversation is not running"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> Result<()> { if std::env::var_os(REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR).is_none() { diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 7a24e91bd4..94465c18da 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -264,7 +264,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s "write outside the workspace", approval_policy, permission_profile, - /*approvals_reviewer*/ None, + Some(ApprovalsReviewer::User), ) .await?; @@ -340,6 +340,7 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu let permission_profile_for_config = permission_profile.clone(); let mut builder = test_codex().with_config(move |config| { + config.include_apply_patch_tool = true; config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .permissions @@ -367,7 +368,10 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu } else { "patched-via-request-permissions" }; - let requested_file = requested_dir.path().join(requested_file_name); + let requested_file = requested_dir + .path() + .canonicalize()? + .join(requested_file_name); let requested_permissions = requested_directory_write_permissions(requested_dir.path()); let normalized_requested_permissions = normalized_directory_write_permissions(requested_dir.path())?; @@ -422,7 +426,7 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu "patch outside the workspace", approval_policy, permission_profile, - strict_auto_review.then_some(ApprovalsReviewer::User), + Some(ApprovalsReviewer::User), ) .await?; @@ -463,8 +467,7 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu EventMsg::TurnComplete(_) => {} EventMsg::ApplyPatchApprovalRequest(approval) => { panic!( - "unexpected apply_patch approval request after granted permissions: {:?}", - approval.call_id + "unexpected apply_patch approval request after granted permissions: {approval:?}", ) } other => panic!("unexpected event: {other:?}"), 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/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index ccf21cbe9c..df37f01b16 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -1030,3 +1030,92 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_description() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let search_call_id = "tool-search-echo"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_tool_search_call( + search_call_id, + &json!({ + "query": "Echo back the provided message and include environment data.", + "limit": 8, + }), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let rmcp_test_server_bin = stdio_server_bin()?; + let mut builder = + configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| { + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "rmcp".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: Some(vec!["echo".to_string()]), + disabled_tools: None, + scopes: None, + oauth_resource: None, + supports_parallel_tool_calls: false, + tools: HashMap::new(), + }, + ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_approval_and_permission_profile( + "Find the rmcp echo tool.", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + + let tools = tool_search_output_tools(&requests[1], search_call_id); + let rmcp_namespace = tools + .iter() + .find(|tool| tool.get("name").and_then(Value::as_str) == Some("mcp__rmcp__")) + .expect("tool_search should return the rmcp namespace"); + assert_eq!( + rmcp_namespace.get("description").and_then(Value::as_str), + Some("Use these tools to exercise the rmcp test server.") + ); + + Ok(()) +} 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-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") +} diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index c42159a6dd..0840b2a909 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -198,7 +198,7 @@ set -euo pipefail for arg in "$@"; do if [[ "${arg}" == "--help" ]]; then - echo "Usage: bwrap --argv0" + echo "Usage: bwrap --argv0 --perms" exit 0 fi done diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index 2a26ec3c7e..f83b54504a 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -20,6 +20,7 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); 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/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index f6f6d35f21..5eda08c13d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -181,6 +181,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { item: command_item, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); assert_eq!( started, @@ -216,6 +217,7 @@ fn command_execution_started_and_completed_translate_to_thread_events() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); assert_eq!( @@ -250,6 +252,7 @@ fn empty_reasoning_items_are_ignored() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -274,6 +277,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -295,6 +299,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -327,6 +332,7 @@ fn reasoning_items_emit_summary_not_raw_content() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -362,6 +368,7 @@ fn web_search_completion_preserves_query_and_action() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -399,6 +406,7 @@ fn web_search_start_and_completion_reuse_item_id() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( @@ -413,6 +421,7 @@ fn web_search_start_and_completion_reuse_item_id() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -472,6 +481,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { @@ -492,6 +502,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -559,6 +570,7 @@ fn mcp_tool_call_failure_sets_failed_status() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -604,6 +616,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { @@ -627,6 +640,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -695,6 +709,7 @@ fn collab_spawn_begin_and_end_emit_item_events() { }, thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( ItemCompletedNotification { @@ -717,6 +732,7 @@ fn collab_spawn_begin_and_end_emit_item_events() { }, thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -795,6 +811,7 @@ fn file_change_completion_maps_change_kinds() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -845,6 +862,7 @@ fn file_change_declined_maps_to_failed_status() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -882,6 +900,7 @@ fn agent_message_item_updates_final_message() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -916,6 +935,7 @@ fn agent_message_item_started_is_ignored() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); assert_eq!( @@ -940,6 +960,7 @@ fn reasoning_item_completed_uses_synthetic_id() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -1296,6 +1317,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, })); assert_eq!( started, @@ -1378,6 +1400,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -1426,6 +1449,7 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); @@ -1470,6 +1494,7 @@ fn failed_turn_clears_stale_final_message() { }, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, }, )); diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index cfaa70f938..577ef52405 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -23,6 +23,12 @@ struct SystemBwrapLauncher { supports_argv0: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SystemBwrapCapabilities { + supports_argv0: bool, + supports_perms: bool, +} + pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { match preferred_bwrap_launcher() { BubblewrapLauncher::System(launcher) => { @@ -43,18 +49,24 @@ fn preferred_bwrap_launcher() -> BubblewrapLauncher { } fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { - preferred_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_supports_argv0) + preferred_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) } fn preferred_bwrap_launcher_for_path_with_probe( system_bwrap_path: &Path, - system_bwrap_supports_argv0: impl FnOnce(&Path) -> bool, + system_bwrap_capabilities: impl FnOnce(&Path) -> Option, ) -> BubblewrapLauncher { if !system_bwrap_path.is_file() { return BubblewrapLauncher::Vendored; } - let supports_argv0 = system_bwrap_supports_argv0(system_bwrap_path); + let Some(SystemBwrapCapabilities { + supports_argv0, + supports_perms: true, + }) = system_bwrap_capabilities(system_bwrap_path) + else { + return BubblewrapLauncher::Vendored; + }; let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, Err(err) => panic!( @@ -75,7 +87,7 @@ pub(crate) fn preferred_bwrap_supports_argv0() -> bool { } } -fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { +fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option { // bubblewrap added `--argv0` in v0.9.0: // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 // Older distro packages (for example Ubuntu 20.04/22.04) ship builds that @@ -83,11 +95,14 @@ fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { // in that case. let output = match Command::new(system_bwrap_path).arg("--help").output() { Ok(output) => output, - Err(_) => return false, + Err(_) => return None, }; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - stdout.contains("--argv0") || stderr.contains("--argv0") + Some(SystemBwrapCapabilities { + supports_argv0: stdout.contains("--argv0") || stderr.contains("--argv0"), + supports_perms: stdout.contains("--perms") || stderr.contains("--perms"), + }) } fn exec_system_bwrap( @@ -164,7 +179,12 @@ mod tests { let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| true), + preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + Some(SystemBwrapCapabilities { + supports_argv0: true, + supports_perms: true, + }) + }), BubblewrapLauncher::System(SystemBwrapLauncher { program: expected, supports_argv0: true, @@ -178,7 +198,12 @@ mod tests { let fake_bwrap_path = fake_bwrap.path(); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| false), + preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + Some(SystemBwrapCapabilities { + supports_argv0: false, + supports_perms: true, + }) + }), BubblewrapLauncher::System(SystemBwrapLauncher { program: AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"), supports_argv0: false, @@ -186,6 +211,21 @@ mod tests { ); } + #[test] + fn falls_back_to_vendored_when_system_bwrap_lacks_perms() { + let fake_bwrap = NamedTempFile::new().expect("temp file"); + + assert_eq!( + preferred_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { + Some(SystemBwrapCapabilities { + supports_argv0: false, + supports_perms: false, + }) + }), + BubblewrapLauncher::Vendored + ); + } + #[test] fn falls_back_to_vendored_when_system_bwrap_is_missing() { assert_eq!( 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/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs index 2bee24972b..da2ef6e02c 100644 --- a/codex-rs/protocol/src/dynamic_tools.rs +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -23,6 +23,8 @@ pub struct DynamicToolCallRequest { pub call_id: String, pub turn_id: String, #[serde(default)] + pub started_at_ms: i64, + #[serde(default)] pub namespace: Option, pub tool: String, pub arguments: JsonValue, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 95f61eba88..a84685cb91 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1828,6 +1828,7 @@ pub struct ItemStartedEvent { pub thread_id: ThreadId, pub turn_id: String, pub item: TurnItem, + pub started_at_ms: i64, } impl HasLegacyEvent for ItemStartedEvent { @@ -1854,6 +1855,15 @@ pub struct ItemCompletedEvent { pub thread_id: ThreadId, pub turn_id: String, pub item: TurnItem, + // Old rollout files may contain ItemCompleted events for PlanItem without + // this field. Default to 0 so those persisted rollouts still deserialize + // after tightening the core event contract. + #[serde(default = "default_item_completed_at_ms")] + pub completed_at_ms: i64, +} + +const fn default_item_completed_at_ms() -> i64 { + 0 } pub trait HasLegacyEvent { @@ -2348,6 +2358,8 @@ pub struct DynamicToolCallResponseEvent { pub call_id: String, /// Turn ID that this dynamic tool call belongs to. pub turn_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// Dynamic tool namespace, when one was provided. #[serde(default)] pub namespace: Option, @@ -3058,6 +3070,8 @@ pub struct ExecCommandBeginEvent { pub process_id: Option, /// Turn ID that this command belongs to. pub turn_id: String, + #[serde(default)] + pub started_at_ms: i64, /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. @@ -3082,6 +3096,8 @@ pub struct ExecCommandEndEvent { pub process_id: Option, /// Turn ID that this command belongs to. pub turn_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// The command that was executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. @@ -3750,6 +3766,8 @@ pub enum TurnAbortReason { pub struct CollabAgentSpawnBeginEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub started_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the @@ -3789,6 +3807,8 @@ pub struct CollabAgentStatusEntry { pub struct CollabAgentSpawnEndEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the newly spawned agent, if it was created. @@ -3814,6 +3834,8 @@ pub struct CollabAgentSpawnEndEvent { pub struct CollabAgentInteractionBeginEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub started_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receiver. @@ -3827,6 +3849,8 @@ pub struct CollabAgentInteractionBeginEvent { pub struct CollabAgentInteractionEndEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receiver. @@ -3846,6 +3870,8 @@ pub struct CollabAgentInteractionEndEvent { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct CollabWaitingBeginEvent { + #[serde(default)] + pub started_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receivers. @@ -3863,6 +3889,8 @@ pub struct CollabWaitingEndEvent { pub sender_thread_id: ThreadId, /// ID of the waiting call. pub call_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// Optional receiver metadata paired with final statuses. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub agent_statuses: Vec, @@ -3874,6 +3902,8 @@ pub struct CollabWaitingEndEvent { pub struct CollabCloseBeginEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub started_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receiver. @@ -3884,6 +3914,8 @@ pub struct CollabCloseBeginEvent { pub struct CollabCloseEndEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receiver. @@ -3903,6 +3935,8 @@ pub struct CollabCloseEndEvent { pub struct CollabResumeBeginEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub started_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receiver. @@ -3919,6 +3953,8 @@ pub struct CollabResumeBeginEvent { pub struct CollabResumeEndEvent { /// Identifier for the collab tool call. pub call_id: String, + #[serde(default)] + pub completed_at_ms: i64, /// Thread ID of the sender. pub sender_thread_id: ThreadId, /// Thread ID of the receiver. @@ -4596,6 +4632,7 @@ mod tests { queries: None, }, }), + started_at_ms: 0, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4612,6 +4649,7 @@ mod tests { thread_id: ThreadId::new(), turn_id: "turn-1".into(), item: TurnItem::UserMessage(UserMessageItem::new(&[])), + started_at_ms: 0, }; assert!( @@ -4633,6 +4671,7 @@ mod tests { result: String::new(), saved_path: None, }), + started_at_ms: 0, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4648,6 +4687,7 @@ mod tests { let event = ItemStartedEvent { thread_id: ThreadId::new(), turn_id: "turn-1".into(), + started_at_ms: 0, item: TurnItem::FileChange(FileChangeItem { id: "patch-1".into(), changes: [( @@ -4683,6 +4723,7 @@ mod tests { let event = ItemStartedEvent { thread_id: ThreadId::new(), turn_id: "turn-1".into(), + started_at_ms: 0, item: TurnItem::McpToolCall(McpToolCallItem { id: "mcp-1".into(), server: "server".into(), @@ -4724,6 +4765,7 @@ mod tests { result: "Zm9v".into(), saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), }), + completed_at_ms: 0, }; let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false); @@ -4748,6 +4790,7 @@ mod tests { let event = ItemCompletedEvent { thread_id: ThreadId::new(), turn_id: "turn-1".into(), + completed_at_ms: 0, item: TurnItem::FileChange(FileChangeItem { id: "patch-1".into(), changes: [( @@ -4785,6 +4828,7 @@ mod tests { let event = ItemCompletedEvent { thread_id: ThreadId::new(), turn_id: "turn-1".into(), + completed_at_ms: 0, item: TurnItem::McpToolCall(McpToolCallItem { id: "mcp-1".into(), server: "server".into(), @@ -4821,6 +4865,34 @@ mod tests { } } + #[test] + fn item_started_event_requires_started_at_ms() { + let mut value = serde_json::to_value(ItemStartedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + item: TurnItem::UserMessage(UserMessageItem::new(&[])), + started_at_ms: 123, + }) + .unwrap(); + value.as_object_mut().unwrap().remove("started_at_ms"); + + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn item_completed_event_defaults_missing_completed_at_ms() { + let mut value = serde_json::to_value(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + item: TurnItem::UserMessage(UserMessageItem::new(&[])), + completed_at_ms: 123, + }) + .unwrap(); + value.as_object_mut().unwrap().remove("completed_at_ms"); + + let event = serde_json::from_value::(value).unwrap(); + assert_eq!(event.completed_at_ms, 0); + } #[test] fn rollback_failed_error_does_not_affect_turn_status() { let event = ErrorEvent { diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index cb83da5e61..7add4d05f5 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -401,6 +401,7 @@ impl ServerHandler for TestToolServer { )])); ServerInfo { + instructions: Some("Use these tools to exercise the rmcp test server.".to_string()), capabilities, ..ServerInfo::default() } diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index 4046beb635..d65ddd3d5b 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -53,6 +53,7 @@ pub use list::read_thread_item_from_rollout; pub use list::rollout_date_parts; pub use metadata::builder_from_items; pub use policy::EventPersistenceMode; +pub use policy::is_persisted_rollout_item; pub use policy::should_persist_response_item_for_memories; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; 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/policy.rs b/codex-rs/rollout/src/policy.rs index e9034fc8ee..aeb92ade11 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -11,7 +11,7 @@ pub enum EventPersistenceMode { /// Whether a rollout `item` should be persisted in rollout files for the /// provided persistence `mode`. -pub fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool { +pub fn is_persisted_rollout_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool { match item { RolloutItem::ResponseItem(item) => should_persist_response_item(item), RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, mode), @@ -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(_) diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index dc2f08b7ab..512c223263 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -45,7 +45,7 @@ use super::list::parse_cursor; use super::list::parse_timestamp_uuid_from_filename; use super::metadata; use super::policy::EventPersistenceMode; -use super::policy::is_persisted_response_item; +use super::policy::is_persisted_rollout_item; use super::session_index::find_thread_names_by_ids; use crate::config::RolloutConfigView; use crate::default_client::originator; @@ -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,17 +785,13 @@ 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 { // Note that function calls may look a bit strange if they are // "fully qualified MCP tool calls," so we could consider // reformatting them in that case. - if is_persisted_response_item(item, self.event_persistence_mode) { + if is_persisted_rollout_item(item, self.event_persistence_mode) { filtered.push(sanitize_rollout_item_for_persistence( item.clone(), self.event_persistence_mode, 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/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_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index 9b3dd5df9f..623118bbc1 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -27,7 +27,7 @@ pub struct ToolSearchSourceInfo { pub struct ToolSearchSource<'a> { pub server_name: &'a str, pub connector_name: Option<&'a str>, - pub connector_description: Option<&'a str>, + pub description: Option<&'a str>, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -37,7 +37,7 @@ pub struct ToolSearchResultSource<'a> { pub tool_name: &'a str, pub tool: &'a rmcp::model::Tool, pub connector_name: Option<&'a str>, - pub connector_description: Option<&'a str>, + pub description: Option<&'a str>, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -215,7 +215,7 @@ pub fn tool_search_result_source_to_loadable_tool_spec( fn tool_search_result_source_namespace_description(source: ToolSearchResultSource<'_>) -> String { source - .connector_description + .description .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string) @@ -251,7 +251,7 @@ pub fn collect_tool_search_source_infos<'a>( return Some(ToolSearchSourceInfo { name: name.to_string(), description: tool - .connector_description + .description .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string), @@ -265,7 +265,11 @@ pub fn collect_tool_search_source_infos<'a>( Some(ToolSearchSourceInfo { name: name.to_string(), - description: None, + description: tool + .description + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string), }) }) .collect() diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index c00c7c8b13..8fa257b1ec 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); @@ -280,7 +282,7 @@ pub fn build_tool_registry_plan( ToolSearchSource { server_name: tool.server_name, connector_name: tool.connector_name, - connector_description: tool.connector_description, + description: tool.description, } })) }) @@ -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..6f48a9e3b5 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()); @@ -1413,7 +1414,7 @@ fn search_tool_description_lists_each_mcp_source_once() { "mcp__rmcp__", "rmcp", /*connector_name*/ None, - /*connector_description*/ None, + Some("Remote memory tools."), ), ]), &[], @@ -1432,7 +1433,7 @@ fn search_tool_description_lists_each_mcp_source_once() { .count(), 1 ); - assert!(description.contains("- rmcp")); + assert!(description.contains("- rmcp: Remote memory tools.")); assert!(!description.contains("mcp__rmcp__echo")); assert!(handlers.contains(&ToolHandlerSpec { @@ -1453,7 +1454,7 @@ fn search_tool_requires_model_capability_and_enabled_feature() { "mcp__codex_apps__calendar", CODEX_APPS_MCP_SERVER_NAME, Some("Calendar"), - /*connector_description*/ None, + /*description*/ None, )]); let features = Features::with_defaults(); @@ -2354,13 +2355,13 @@ fn deferred_mcp_tool<'a>( tool_namespace: &'a str, server_name: &'a str, connector_name: Option<&'a str>, - connector_description: Option<&'a str>, + description: Option<&'a str>, ) -> ToolRegistryPlanDeferredTool<'a> { ToolRegistryPlanDeferredTool { name: ToolName::namespaced(tool_namespace, tool_name), server_name, connector_name, - connector_description, + description, } } diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index f6ea215ac1..7c3772783d 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -85,7 +85,7 @@ pub struct ToolRegistryPlanDeferredTool<'a> { pub name: ToolName, pub server_name: &'a str, pub connector_name: Option<&'a str>, - pub connector_description: Option<&'a str>, + pub description: Option<&'a str>, } impl ToolRegistryPlan { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1327c73e0a..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; @@ -144,6 +146,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; @@ -302,8 +305,8 @@ struct AutoReviewMode { } /// Enabling the Auto-review experiment in the TUI should also switch the -/// current `/approvals` settings to the matching Auto-review mode. Users -/// can still change `/approvals` afterward; this just assumes that opting into +/// current `/permissions` settings to the matching Auto-review mode. Users +/// can still change `/permissions` afterward; this just assumes that opting into /// the experiment means they want Auto-review enabled immediately. fn auto_review_mode() -> AutoReviewMode { AutoReviewMode { @@ -431,8 +434,10 @@ 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, pub(crate) active_profile: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, @@ -572,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(), @@ -609,6 +615,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; @@ -709,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; @@ -731,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(), @@ -766,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(), @@ -806,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(), @@ -853,7 +866,9 @@ 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, cli_kv_overrides, harness_overrides, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 09dd402cd5..69eba70cf9 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -203,7 +203,7 @@ impl App { let previous_approvals_reviewer = feature_config.approvals_reviewer; if effective_enabled { // Persist the reviewer setting so future sessions keep the - // experiment's matching `/approvals` mode until the user + // experiment's matching `/permissions` mode until the user // changes it explicitly. feature_config.approvals_reviewer = auto_review_preset.approvals_reviewer; feature_edits.push(ConfigEdit::SetPath { @@ -323,7 +323,7 @@ impl App { .await; // This uses `OverrideTurnContext` intentionally: toggling the // experiment should update the active thread's effective approval - // settings immediately, just like a `/approvals` selection. Without + // settings immediately, just like a `/permissions` selection. Without // this runtime patch, the config edit would only affect future // sessions or turns recreated from disk. let op = AppCommand::override_turn_context( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 5af8e7d2a7..aefab7a1b3 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 @@ -1847,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(); } @@ -1926,6 +1931,9 @@ impl App { self.chat_widget .open_keymap_capture(context, action, intent, &self.keymap); } + AppEvent::OpenKeymapDebug => { + self.chat_widget.open_keymap_debug(&self.keymap); + } AppEvent::KeymapCaptured { context, action, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index fc18b62739..f223db9bb3 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -122,12 +122,22 @@ impl App { return; } - if self.keymap.app.toggle_vim_mode.is_pressed(key_event) { + let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available(); + + if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) { self.chat_widget.toggle_vim_mode_and_notify(); return; } - if self.keymap.app.open_transcript.is_pressed(key_event) { + 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(); self.overlay = Some(Overlay::new_transcript( @@ -138,7 +148,9 @@ impl App { return; } - if self.keymap.app.open_external_editor.is_pressed(key_event) { + if app_keymap_shortcuts_available + && self.keymap.app.open_external_editor.is_pressed(key_event) + { // Only launch the external editor if there is no overlay and the bottom pane is not in use. // Note that it can be launched while a task is running to enable editing while the previous turn is ongoing. if self.overlay.is_none() @@ -166,7 +178,9 @@ impl App { } match key_event { - _ if self.keymap.app.clear_terminal.is_pressed(key_event) => { + _ if app_keymap_shortcuts_available + && self.keymap.app.clear_terminal.is_pressed(key_event) => + { if !self.chat_widget.can_run_ctrl_l_clear_now() { return; } @@ -217,7 +231,27 @@ impl App { && !self.chat_widget.should_handle_vim_insert_escape(key_event) } + fn app_keymap_shortcuts_available(&self) -> bool { + self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() + } + pub(super) fn refresh_status_line(&mut self) { self.chat_widget.refresh_status_line(); } } + +#[cfg(test)] +mod tests { + use super::super::test_support::make_test_app; + + #[tokio::test] + async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() { + let mut app = make_test_app().await; + assert!(app.app_keymap_shortcuts_available()); + + let keymap = app.keymap.clone(); + app.chat_widget.open_keymap_debug(&keymap); + + assert!(!app.app_keymap_shortcuts_available()); + } +} 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..f34e22203f 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -19,7 +19,9 @@ 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, 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..dacfabcb72 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(), @@ -2633,6 +2634,7 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), + started_at_ms: 0, item: ThreadItem::FileChange { id: "patch-approval".to_string(), changes: vec![FileUpdateChange { @@ -3770,7 +3772,9 @@ async fn make_test_app() -> App { session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, + state_db: None, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -3831,7 +3835,9 @@ 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, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -4731,6 +4737,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(), @@ -4760,6 +4767,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { codex_app_server_protocol::ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: ThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: codex_app_server_protocol::CollabAgentTool::Wait, 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/app_event.rs b/codex-rs/tui/src/app_event.rs index 1032823f24..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, @@ -873,6 +878,9 @@ pub(crate) enum AppEvent { intent: KeymapEditIntent, }, + /// Open the keymap keypress inspector. + OpenKeymapDebug, + /// Apply a captured key to the selected keymap action. KeymapCaptured { context: String, diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 780ae77f2a..7538d76ca7 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -130,4 +130,9 @@ pub(crate) trait BottomPaneView: Renderable { fn terminal_title_requires_action(&self) -> bool { false } + + /// Return the next time-based redraw this view needs while it is active. + fn next_frame_delay(&self) -> Option { + None + } } 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/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1ec258fb35..1618d64025 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -16,8 +16,7 @@ use crate::slash_command::SlashCommand; // Hide alias commands in the default popup list so each unique action appears once. // `quit` is an alias of `exit`, so we skip `quit` here. -// `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; +const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit]; const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new( ColumnWidthMode::AutoAllRows, /*name_column_width*/ None, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2daec54829..c839ddd4a7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -466,6 +466,7 @@ impl BottomPane { fn push_view(&mut self, view: Box) { self.view_stack.push(view); + self.schedule_active_view_frame(); self.request_redraw(); } @@ -714,6 +715,16 @@ impl BottomPane { fn pre_draw_tick_at(&mut self, now: Instant) { self.composer.sync_popups(); self.maybe_show_delayed_approval_requests_at(now); + self.schedule_active_view_frame(); + } + + fn schedule_active_view_frame(&self) { + if let Some(delay) = self + .active_view() + .and_then(BottomPaneView::next_frame_delay) + { + self.request_redraw_in(delay); + } } /// Replace the composer text with `text`. @@ -1533,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/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 510294a5f1..44e3898db6 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -154,12 +154,7 @@ const PASTE_BURST_MIN_CHARS: u16 = 3; const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); // Maximum delay between consecutive chars to be considered part of a paste burst. -// Windows terminals (especially VS Code integrated terminal) deliver paste events -// more slowly than native terminals, so we use a higher threshold there. -#[cfg(not(windows))] const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); -#[cfg(windows)] -const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(30); // Idle timeout before flushing buffered paste content. // Slower paste bursts have been observed in Windows environments. 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/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 268665ef06..0638412110 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"); @@ -2719,6 +2779,53 @@ mod tests { assert_eq!(t.cursor(), 6); } + #[test] + fn shift_backspace_and_shift_delete_keep_grapheme_delete_behavior() { + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 2); + + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SHIFT)); + assert_eq!(t.text(), "ac"); + assert_eq!(t.cursor(), 1); + + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 1); + + t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::SHIFT)); + assert_eq!(t.text(), "ac"); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn control_backspace_variants_delete_backward_word() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + + t.input(KeyEvent::new(KeyCode::Backspace, modifiers)); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + } + } + + #[test] + fn control_delete_variants_delete_forward_word() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 0); + + t.input(KeyEvent::new(KeyCode::Delete, modifiers)); + assert_eq!(t.text(), " world"); + assert_eq!(t.cursor(), 0); + } + } + #[test] fn delete_backward_word_handles_narrow_no_break_space() { let mut t = ta_with("32\u{202F}AM"); 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 e315f0d60f..8a61e79fe0 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, @@ -8120,7 +8166,7 @@ impl ChatWidget { .send(AppEvent::PersistModelSelection { model, effort }); } - /// Open the permissions popup (alias for /permissions). + /// Open the permissions popup. pub(crate) fn open_approvals_popup(&mut self) { self.open_permissions_popup(); } @@ -8693,8 +8739,8 @@ impl ChatWidget { // new permission profile, so downstream policy-change hooks don't // re-trigger the warning. let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals or - // /permissions), to avoid duplicate warnings from the ensuing policy change. + // Suppress the immediate re-scan only when a preset will be applied via + // /permissions, to avoid duplicate warnings from the ensuing policy change. if preset.is_some() { accept_actions.push(Box::new(|tx| { tx.send(AppEvent::SkipNextWorldWritableScan); @@ -9267,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, @@ -9321,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 bd4df0a362..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); } @@ -85,6 +86,13 @@ impl ChatWidget { self.request_redraw(); } + /// Opens the keypress inspector with the current runtime bindings. + pub(crate) fn open_keymap_debug(&mut self, runtime_keymap: &RuntimeKeymap) { + let view = keymap_setup::build_keymap_debug_view(runtime_keymap, &self.config.tui_keymap); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + /// Opens the menu that lets the user choose which existing binding to replace. /// /// This is only used for actions with multiple effective bindings. The chosen binding is @@ -113,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, ); @@ -128,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, ); @@ -139,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 24a634cd0b..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() { @@ -237,9 +232,6 @@ impl ChatWidget { SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); } - SlashCommand::Approvals => { - self.open_permissions_popup(); - } SlashCommand::Permissions => { self.open_permissions_popup(); } @@ -582,6 +574,20 @@ impl ChatWidget { "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), }, + SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { + "" => self.open_keymap_picker(), + "debug" => { + match crate::keymap::RuntimeKeymap::from_config(&self.config.tui_keymap) { + Ok(runtime_keymap) => self.open_keymap_debug(&runtime_keymap), + Err(err) => { + self.add_error_message(format!( + "Invalid `tui.keymap` configuration: {err}" + )); + } + } + } + _ => self.add_error_message("Usage: /keymap [debug]".to_string()), + }, SlashCommand::Rename if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; @@ -876,7 +882,6 @@ impl ChatWidget { | SlashCommand::Keymap | SlashCommand::Agent | SlashCommand::MultiAgents - | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot 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/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 13b86e2afb..a486c0e0ea 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -16,6 +16,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, @@ -34,6 +35,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, @@ -90,6 +92,7 @@ async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: "user-1".to_string(), content: vec![AppServerUserInput::Text { @@ -135,6 +138,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::AgentMessage { id: "msg-1".to_string(), text: "Yes. What do you need?".to_string(), @@ -287,6 +291,7 @@ async fn live_app_server_file_change_item_started_preserves_changes() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: AppServerThreadItem::FileChange { id: "patch-1".to_string(), changes: vec![FileUpdateChange { @@ -320,6 +325,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command: command.clone(), @@ -341,6 +347,7 @@ async fn live_app_server_command_execution_strips_shell_wrapper() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command, @@ -396,6 +403,7 @@ async fn live_app_server_collab_wait_items_render_history() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, @@ -418,6 +426,7 @@ async fn live_app_server_collab_wait_items_render_history() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, @@ -471,6 +480,7 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, @@ -490,6 +500,7 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04f7e3d907..8f3d37e76c 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, @@ -654,6 +659,7 @@ pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) { .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + completed_at_ms: 0, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: Vec::new(), @@ -672,6 +678,7 @@ pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Int .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + started_at_ms: 0, item: AppServerThreadItem::EnteredReviewMode { id: "review-start".to_string(), review: review.into(), @@ -700,6 +707,7 @@ pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) { .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + completed_at_ms: 0, item: AppServerThreadItem::ExitedReviewMode { id: "review-end".to_string(), review: String::new(), @@ -756,6 +764,7 @@ pub(super) fn handle_patch_apply_begin( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id(chat), turn_id: turn_id.into(), + started_at_ms: 0, item: AppServerThreadItem::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), @@ -777,6 +786,7 @@ pub(super) fn handle_patch_apply_end( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: turn_id.into(), + completed_at_ms: 0, item: AppServerThreadItem::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), @@ -796,6 +806,7 @@ pub(super) fn handle_view_image_tool_call( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::ImageView { id: call_id.into(), path, @@ -815,6 +826,7 @@ pub(super) fn handle_image_generation_end( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::ImageGeneration { id: call_id.into(), status: "completed".to_string(), @@ -972,6 +984,7 @@ pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + started_at_ms: 0, item, }), /*replay_kind*/ None, @@ -1011,6 +1024,7 @@ pub(super) fn complete_assistant_message( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::AgentMessage { id: item_id.to_string(), text: text.to_string(), @@ -1053,6 +1067,7 @@ pub(super) fn complete_user_message_for_inputs( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: item_id.to_string(), content, @@ -1194,6 +1209,7 @@ pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), + completed_at_ms: 0, item, }), /*replay_kind*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index d801870bca..180412cae7 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -801,6 +801,7 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], 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/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 8b85167604..0924d724fa 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -154,6 +154,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: 0, item: review_mode_item.clone(), }), /*replay_kind*/ None, @@ -166,6 +167,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: review_mode_item, }), /*replay_kind*/ None, @@ -176,6 +178,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: "review-prompt".to_string(), content: vec![AppServerUserInput::Text { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 53312a7f81..2832377375 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1149,6 +1149,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: String::new(), turn_id: "turn-1".to_string(), + completed_at_ms: 0, item: AppServerThreadItem::Plan { id: "plan-1".to_string(), text: plan_text.clone(), @@ -1228,6 +1229,103 @@ async fn keymap_capture_can_capture_current_copy_shortcut() { ); } +#[tokio::test] +async fn slash_keymap_capture_can_capture_app_shortcuts() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let runtime_keymap = crate::keymap::RuntimeKeymap::defaults(); + + for (key, expected) in [('t', "ctrl-t"), ('l', "ctrl-l"), ('g', "ctrl-g")] { + chat.open_keymap_capture( + "global".to_string(), + "open_transcript".to_string(), + crate::app_event::KeymapEditIntent::ReplaceAll, + &runtime_keymap, + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); + + let AppEvent::KeymapCaptured { + context, + action, + key, + intent, + } = rx.try_recv().expect("captured key event") + else { + panic!("expected keymap capture event"); + }; + assert_eq!(context, "global"); + assert_eq!(action, "open_transcript"); + assert_eq!(key, expected); + assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll); + } +} + +#[tokio::test] +async fn slash_keymap_debug_opens_keypress_inspector() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new()); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("Keypress Inspector")); + assert!(popup.contains("Waiting for a keypress")); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!(popup.contains("global.copy (Copy)")); + assert!( + drain_insert_history(&mut rx).is_empty(), + "debug inspector should open without transcript messages" + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_keymap_debug_can_inspect_app_shortcuts() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new()); + + for (key, expected_action) in [ + ('t', "global.open_transcript (Open Transcript)"), + ('l', "global.clear_terminal (Clear Terminal)"), + ('g', "global.open_external_editor (Open External Editor)"), + ] { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains(expected_action), + "expected {expected_action:?} in debug popup for ctrl-{key}, got {popup:?}" + ); + } + + assert!( + drain_insert_history(&mut rx).is_empty(), + "debug inspector should not run app shortcut side effects" + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_keymap_invalid_args_show_usage() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/keymap nope"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /keymap [debug]"), + "expected usage message, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/keymap nope"); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn copy_shortcut_can_be_remapped() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1720,6 +1818,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/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/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f7b4ff3986..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. @@ -44,18 +45,39 @@ impl KeyBinding { Self { key, modifiers } } + pub(crate) fn from_event(event: KeyEvent) -> Self { + 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) } pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) { (self.key, self.modifiers) } + + pub(crate) fn display_label(&self) -> String { + let modifiers = modifiers_to_string(self.modifiers); + let key = match self.key { + KeyCode::Enter => "enter".to_string(), + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Up => "↑".to_string(), + KeyCode::Down => "↓".to_string(), + KeyCode::Left => "←".to_string(), + KeyCode::Right => "→".to_string(), + KeyCode::PageUp => "pgup".to_string(), + KeyCode::PageDown => "pgdn".to_string(), + _ => self.key.to_string().to_ascii_lowercase(), + }; + format!("{modifiers}{key}") + } } -fn normalize_shifted_ascii_char( +pub(crate) fn normalize_key_parts( key: KeyCode, mut modifiers: KeyModifiers, ) -> (KeyCode, KeyModifiers) { @@ -75,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, } } @@ -143,20 +163,7 @@ impl From for Span<'static> { } impl From<&KeyBinding> for Span<'static> { fn from(binding: &KeyBinding) -> Self { - let KeyBinding { key, modifiers } = binding; - let modifiers = modifiers_to_string(*modifiers); - let key = match key { - KeyCode::Enter => "enter".to_string(), - KeyCode::Char(' ') => "space".to_string(), - KeyCode::Up => "↑".to_string(), - KeyCode::Down => "↓".to_string(), - KeyCode::Left => "←".to_string(), - KeyCode::Right => "→".to_string(), - KeyCode::PageUp => "pgup".to_string(), - KeyCode::PageDown => "pgdn".to_string(), - _ => format!("{key}").to_ascii_lowercase(), - }; - Span::styled(format!("{modifiers}{key}"), key_hint_style()) + Span::styled(binding.display_label(), key_hint_style()) } } @@ -248,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..5e88423b22 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(','))], @@ -578,11 +588,21 @@ impl RuntimeKeymap { move_line_end: default_bindings![plain(KeyCode::End), ctrl(KeyCode::Char('e'))], delete_backward: default_bindings![ plain(KeyCode::Backspace), + shift(KeyCode::Backspace), ctrl(KeyCode::Char('h')) ], - delete_forward: default_bindings![plain(KeyCode::Delete), ctrl(KeyCode::Char('d'))], + delete_forward: default_bindings![ + plain(KeyCode::Delete), + shift(KeyCode::Delete), + ctrl(KeyCode::Char('d')) + ], delete_backward_word: default_bindings![ alt(KeyCode::Backspace), + ctrl(KeyCode::Backspace), + raw(KeyBinding::new( + KeyCode::Backspace, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )), ctrl(KeyCode::Char('w')), raw(KeyBinding::new( KeyCode::Char('h'), @@ -591,9 +611,15 @@ impl RuntimeKeymap { ], delete_forward_word: default_bindings![ alt(KeyCode::Delete), + ctrl(KeyCode::Delete), + raw(KeyBinding::new( + KeyCode::Delete, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )), 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 +753,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 +794,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 +836,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 +885,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 +933,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 +970,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 +1424,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 +1623,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 +1644,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 +1770,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 +1866,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 +1884,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(); @@ -1824,6 +1934,54 @@ mod tests { ); } + #[test] + fn default_editor_deletion_includes_modified_backspace_delete_aliases() { + let runtime = RuntimeKeymap::defaults(); + + assert!( + runtime + .editor + .delete_backward + .contains(&key_hint::shift(KeyCode::Backspace)) + ); + assert!( + runtime + .editor + .delete_forward + .contains(&key_hint::shift(KeyCode::Delete)) + ); + assert!( + runtime + .editor + .delete_backward_word + .contains(&key_hint::ctrl(KeyCode::Backspace)) + ); + assert!( + runtime + .editor + .delete_backward_word + .contains(&KeyBinding::new( + KeyCode::Backspace, + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )) + ); + assert!( + runtime + .editor + .delete_forward_word + .contains(&key_hint::ctrl(KeyCode::Delete)) + ); + assert!( + runtime + .editor + .delete_forward_word + .contains(&KeyBinding::new( + KeyCode::Delete, + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )) + ); + } + #[test] fn default_composer_toggle_shortcuts_includes_shift_question_mark() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 17cbfbc050..f67ea20cb0 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -18,11 +18,18 @@ //! surface errors. 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; @@ -47,6 +54,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -55,6 +63,8 @@ use actions::action_label; use actions::binding_slot; use actions::bindings_for_action; use actions::format_binding_summary; +#[cfg(test)] +use debug::KeymapDebugView; pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu"; pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu"; @@ -691,10 +701,10 @@ impl BottomPaneView for KeymapCaptureView { } fn key_event_to_config_key_spec(key_event: KeyEvent) -> Result { - key_parts_to_config_key_spec(key_event.code, key_event.modifiers) + binding_to_config_key_spec(KeyBinding::from_event(key_event)) } -fn binding_to_config_key_spec(binding: crate::key_hint::KeyBinding) -> Result { +fn binding_to_config_key_spec(binding: KeyBinding) -> Result { let (code, modifiers) = binding.parts(); key_parts_to_config_key_spec(code, modifiers) } @@ -703,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( @@ -733,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()); @@ -749,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)] @@ -768,6 +785,7 @@ mod tests { use super::picker::KEYMAP_ALL_TAB_ID; use super::picker::KEYMAP_COMMON_TAB_ID; use super::picker::KEYMAP_CUSTOM_TAB_ID; + use super::picker::KEYMAP_DEBUG_TAB_ID; use super::picker::KEYMAP_UNBOUND_TAB_ID; use super::*; use crate::bottom_pane::BottomPane; @@ -793,6 +811,14 @@ mod tests { buf } + fn render_debug(view: &KeymapDebugView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + render_buffer(&buf) + } + fn render_picker(params: SelectionViewParams, width: u16) -> String { let view = ListSelectionView::new(params, app_event_sender(), RuntimeKeymap::defaults().list); @@ -807,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) @@ -877,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()); @@ -899,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(); @@ -1036,10 +1123,35 @@ 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] + fn picker_debug_tab_is_last_and_opens_inspector() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let debug_tab = params.tabs.last().expect("debug tab"); + + assert_eq!(debug_tab.id, KEYMAP_DEBUG_TAB_ID); + assert_eq!(debug_tab.label, "Debug"); + assert_eq!(debug_tab.items.len(), 1); + assert_eq!(debug_tab.items[0].name, "Inspect keypresses"); + assert_eq!( + debug_tab.items[0].description.as_deref(), + Some("Press Enter to start. Then press any key to inspect it; Ctrl+C exits.") + ); + assert!( + params + .tab_footer_hints + .iter() + .any(|(tab_id, _)| tab_id == KEYMAP_DEBUG_TAB_ID) + ); } #[test] @@ -1234,6 +1346,66 @@ mod tests { ); } + #[test] + fn debug_view_initial_snapshot() { + let view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); + + assert_snapshot!( + "keymap_debug_view_initial", + render_debug(&view, /*width*/ 80) + ); + } + + #[test] + fn debug_view_shows_delayed_missing_key_hint() { + let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); + view.show_delayed_hint_for_test(); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(rendered.contains("Still waiting?")); + assert_snapshot!("keymap_debug_view_delayed_hint", rendered); + } + + #[test] + fn debug_view_reports_detected_key_and_matching_actions() { + let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); + view.show_delayed_hint_for_test(); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(!rendered.contains("Still waiting?")); + assert_snapshot!("keymap_debug_view_match", rendered); + } + + #[test] + fn debug_view_uses_custom_binding_source() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "global", "copy", "ctrl-x").unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + let mut view = build_keymap_debug_view(&runtime, &keymap); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(rendered.contains("global.copy (Copy)")); + assert!(rendered.contains("[Custom]")); + } + + #[test] + fn debug_view_labels_custom_global_fallback_source() { + let mut keymap = TuiKeymap::default(); + keymap.global.queue = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-q".to_string()))); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + let mut view = build_keymap_debug_view(&runtime, &keymap); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(rendered.contains("composer.queue (Queue)")); + assert!(rendered.contains("[Custom global]")); + } + #[test] fn capture_completion_returns_to_selected_keymap_picker_row() { let (mut pane, tx, mut rx) = test_pane(); @@ -1438,10 +1610,46 @@ mod tests { } #[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_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, + )), + Ok("ctrl-p".to_string()) + ); + } + + #[test] + 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 40c6c1bb74..b6f9660bdd 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -15,6 +15,7 @@ use std::collections::BTreeSet; use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; +use crossterm::event::KeyEvent; use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; @@ -29,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( @@ -42,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, + } } } @@ -52,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."), @@ -74,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."), @@ -171,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), @@ -193,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), @@ -272,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()), @@ -294,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()), @@ -374,3 +419,91 @@ pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String { specs.join(", ") } } + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum KeymapDebugBindingSource { + Custom, + CustomGlobal, + Default, +} + +impl KeymapDebugBindingSource { + pub(super) const fn label(&self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::CustomGlobal => "Custom global", + Self::Default => "Default", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct KeymapDebugActionMatch { + pub(super) context: &'static str, + pub(super) action: &'static str, + pub(super) label: String, + pub(super) description: &'static str, + pub(super) source: KeymapDebugBindingSource, +} + +pub(super) fn matching_actions_for_key_event( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + event: KeyEvent, +) -> Vec { + KEYMAP_ACTIONS + .iter() + .filter_map(|descriptor| { + let bindings = + bindings_for_action(runtime_keymap, descriptor.context, descriptor.action)?; + bindings + .iter() + .any(|binding| binding.is_press(event)) + .then(|| KeymapDebugActionMatch { + context: descriptor.context, + action: descriptor.action, + label: action_label(descriptor.action), + description: descriptor.description, + source: debug_binding_source(keymap_config, descriptor), + }) + }) + .collect() +} + +fn debug_binding_source( + keymap_config: &TuiKeymap, + descriptor: &KeymapActionDescriptor, +) -> KeymapDebugBindingSource { + let mut keymap_config = keymap_config.clone(); + let Some(slot) = binding_slot(&mut keymap_config, descriptor.context, descriptor.action) else { + return KeymapDebugBindingSource::Default; + }; + if slot.is_some() { + return KeymapDebugBindingSource::Custom; + } + + let Some(global_slot) = global_fallback_slot(&mut keymap_config, descriptor) else { + return KeymapDebugBindingSource::Default; + }; + if global_slot.is_some() { + KeymapDebugBindingSource::CustomGlobal + } else { + KeymapDebugBindingSource::Default + } +} + +fn global_fallback_slot<'a>( + keymap: &'a mut TuiKeymap, + descriptor: &KeymapActionDescriptor, +) -> Option<&'a mut Option> { + if descriptor.context != "composer" { + return None; + } + + match descriptor.action { + "submit" => Some(&mut keymap.global.submit), + "queue" => Some(&mut keymap.global.queue), + "toggle_shortcuts" => Some(&mut keymap.global.toggle_shortcuts), + _ => None, + } +} diff --git a/codex-rs/tui/src/keymap_setup/debug.rs b/codex-rs/tui/src/keymap_setup/debug.rs new file mode 100644 index 0000000000..f08cdb8f7e --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/debug.rs @@ -0,0 +1,243 @@ +use codex_config::types::TuiKeymap; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::time::Duration; +use std::time::Instant; + +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::key_hint::KeyBinding; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; + +use super::actions; +use super::actions::matching_actions_for_key_event; +use super::key_event_to_config_key_spec; + +const MISSING_KEY_HINT_DELAY: Duration = Duration::from_secs(3); +const SHORT_MISSING_KEY_HINT: &str = "Tip: Codex can only inspect keys your terminal sends."; +const DELAYED_MISSING_KEY_HINT: &str = "Still waiting? If nothing changes when you press a key, your terminal is not sending that key to Codex. Only received keys can be assigned as shortcuts."; + +struct KeymapDebugReport { + detected: KeyBinding, + config_key: Result, + raw_event: String, + matches: Vec, +} + +/// Bottom-pane view for inspecting how terminal key events map to keymap actions. +pub(crate) struct KeymapDebugView { + runtime_keymap: RuntimeKeymap, + keymap_config: TuiKeymap, + opened_at: Instant, + last_report: Option, + complete: bool, +} + +pub(crate) fn build_keymap_debug_view( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> KeymapDebugView { + KeymapDebugView { + runtime_keymap: runtime_keymap.clone(), + keymap_config: keymap_config.clone(), + opened_at: Instant::now(), + last_report: None, + complete: false, + } +} + +impl KeymapDebugView { + fn lines(&self, width: u16) -> Vec> { + self.lines_at(width, Instant::now()) + } + + fn lines_at(&self, width: u16, now: Instant) -> Vec> { + let wrap_width = usize::from(width.max(1)); + let mut lines = vec![ + Line::from("Keypress Inspector".bold()), + Line::from( + "Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes.".dim(), + ), + ]; + let hint = if self.should_show_delayed_hint(now) { + DELAYED_MISSING_KEY_HINT + } else { + SHORT_MISSING_KEY_HINT + }; + push_wrapped_dim(&mut lines, hint.to_string(), wrap_width, "", ""); + + let Some(report) = &self.last_report else { + lines.push(Line::from("")); + lines.push(Line::from("Waiting for a keypress...".cyan())); + return lines; + }; + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + "Detected: ".dim(), + report.detected.display_label().cyan(), + ])); + match &report.config_key { + Ok(config_key) => { + lines.push(Line::from(vec![ + "Config key: ".dim(), + config_key.clone().cyan(), + ])); + } + Err(error) => { + push_wrapped_dim( + &mut lines, + format!("unsupported - {error}"), + wrap_width, + "Config key: ", + " ", + ); + } + } + push_wrapped_dim( + &mut lines, + report.raw_event.clone(), + wrap_width, + "Raw event: ", + " ", + ); + lines.push(Line::from("")); + lines.push(Line::from("Assigned actions:".dim())); + if report.matches.is_empty() { + lines.push(Line::from(" none".dim())); + } else { + for matched_action in &report.matches { + let action = format!( + "{}.{} ({}) - {} [{}]", + matched_action.context, + matched_action.action, + matched_action.label, + matched_action.description, + matched_action.source.label() + ); + push_wrapped_dim(&mut lines, action, wrap_width, " - ", " "); + } + } + lines + } + + fn should_show_delayed_hint(&self, now: Instant) -> bool { + self.last_report.is_none() && now.duration_since(self.opened_at) >= MISSING_KEY_HINT_DELAY + } + + #[cfg(test)] + pub(crate) fn show_delayed_hint_for_test(&mut self) { + self.opened_at = Instant::now() - MISSING_KEY_HINT_DELAY; + } +} + +impl Renderable for KeymapDebugView { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.lines(area.width)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +impl BottomPaneView for KeymapDebugView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + self.last_report = Some(KeymapDebugReport { + detected: KeyBinding::from_event(key_event), + config_key: key_event_to_config_key_spec(key_event), + raw_event: key_event_debug_summary(key_event), + matches: matching_actions_for_key_event( + &self.runtime_keymap, + &self.keymap_config, + key_event, + ), + }); + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn next_frame_delay(&self) -> Option { + if self.last_report.is_some() { + return None; + } + + self.opened_at + .checked_add(MISSING_KEY_HINT_DELAY) + .and_then(|show_at| show_at.checked_duration_since(Instant::now())) + .filter(|delay| !delay.is_zero()) + } +} + +fn push_wrapped_dim( + lines: &mut Vec>, + text: String, + wrap_width: usize, + initial_indent: &'static str, + subsequent_indent: &'static str, +) { + let options = textwrap::Options::new(wrap_width) + .initial_indent(initial_indent) + .subsequent_indent(subsequent_indent); + lines.extend( + textwrap::wrap(&text, options) + .into_iter() + .map(|line| Line::from(line.into_owned().dim())), + ); +} + +fn key_event_debug_summary(key_event: KeyEvent) -> String { + format!( + "code={:?}, modifiers={}, kind={:?}", + key_event.code, + key_modifiers_debug_label(key_event.modifiers), + key_event.kind + ) +} + +fn key_modifiers_debug_label(modifiers: KeyModifiers) -> String { + if modifiers.is_empty() { + return "none".to_string(); + } + + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("ctrl".to_string()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("alt".to_string()); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("shift".to_string()); + } + + let known_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT; + let other_modifiers = modifiers.difference(known_modifiers); + if !other_modifiers.is_empty() { + parts.push(format!("{other_modifiers:?}")); + } + parts.join("|") +} diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 429586bf60..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; @@ -27,6 +28,7 @@ pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts"; pub(super) const KEYMAP_COMMON_TAB_ID: &str = "common-shortcuts"; pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts"; pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts"; +pub(super) const KEYMAP_DEBUG_TAB_ID: &str = "debug-shortcuts"; const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12; const KEYMAP_ROW_PREFIX_WIDTH: usize = KEYMAP_CONTEXT_LABEL_WIDTH + 3; @@ -58,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"), @@ -115,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(); @@ -237,11 +277,13 @@ fn build_keymap_picker_params_for_action( ), }); } + tabs.push(keymap_debug_tab()); SelectionViewParams { view_id: Some(KEYMAP_PICKER_VIEW_ID), header: Box::new(()), footer_hint: Some(keymap_picker_hint_line()), + tab_footer_hints: vec![(KEYMAP_DEBUG_TAB_ID.to_string(), keymap_debug_hint_line())], tabs, initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()), is_searchable: true, @@ -254,12 +296,42 @@ fn build_keymap_picker_params_for_action( } } +fn keymap_debug_tab() -> SelectionTab { + SelectionTab { + id: KEYMAP_DEBUG_TAB_ID.to_string(), + label: "Debug".to_string(), + header: keymap_header( + "Inspect keypresses from your terminal.".to_string(), + "See the key Codex detects and any shortcuts assigned to it.".to_string(), + ), + items: vec![SelectionItem { + name: "Inspect keypresses".to_string(), + description: Some( + "Press Enter to start. Then press any key to inspect it; Ctrl+C exits." + .to_string(), + ), + selected_description: Some( + "Open a live inspector that shows the detected key, config key, and matching actions." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenKeymapDebug); + })], + search_value: Some("debug inspect keypress key terminal detected actions".to_string()), + ..Default::default() + }], + } +} + 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) @@ -391,3 +463,12 @@ fn keymap_picker_hint_line() -> Line<'static> { " close".dim(), ]) } + +fn keymap_debug_hint_line() -> Line<'static> { + Line::from(vec![ + "enter".cyan(), + " start inspector · ".dim(), + "esc".cyan(), + " close".dim(), + ]) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 466b7b9e6e..85aeca9bc5 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; @@ -102,6 +103,7 @@ mod audio_device { } } mod bottom_pane; +mod branch_summary; mod chatwidget; mod cli; mod clipboard_copy; @@ -187,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 { @@ -271,6 +274,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 +285,7 @@ async fn start_embedded_app_server( cloud_requirements, feedback, log_db, + state_db, environment_manager, InProcessAppServerClient::start, ) @@ -398,6 +403,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 +415,7 @@ async fn start_app_server( cloud_requirements, feedback, log_db, + state_db, environment_manager, ) .await @@ -423,6 +430,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 +442,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 +453,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 +472,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 +498,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 +800,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 +869,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 +1047,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 +1073,7 @@ pub async fn run_main( cloud_requirements, feedback, log_db, + state_db, remote_url, remote_auth_token, environment_manager, @@ -1050,6 +1095,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 +1155,7 @@ async fn run_ratatui_app( cloud_requirements.clone(), feedback.clone(), log_db.clone(), + state_db.clone(), environment_manager.clone(), ) .await @@ -1360,7 +1407,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 +1485,7 @@ async fn run_ratatui_app( cloud_requirements.clone(), feedback.clone(), log_db.clone(), + state_db.clone(), environment_manager.clone(), ) .await @@ -1468,6 +1516,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 +1721,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 +1730,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, + state_db, Arc::new(EnvironmentManager::default_for_tests()), ) .await @@ -2026,6 +2077,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..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(); @@ -1048,6 +1065,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/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/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); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index f8e7fd88ec..e05edac6b5 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,7 +15,6 @@ pub enum SlashCommand { Model, Fast, Ide, - Approvals, Permissions, Keymap, Vim, @@ -24,7 +23,7 @@ pub enum SlashCommand { #[strum(serialize = "sandbox-add-read-dir")] SandboxReadRoot, Experimental, - #[strum(to_string = "autoreview")] + #[strum(to_string = "approve")] AutoReview, Memories, Skills, @@ -117,7 +116,6 @@ impl SlashCommand { SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", SlashCommand::Side => "start a side conversation in an ephemeral fork", - SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::Keymap => "remap TUI shortcuts", SlashCommand::Vim => "toggle Vim mode for the composer", @@ -153,6 +151,7 @@ impl SlashCommand { | SlashCommand::Goal | SlashCommand::Fast | SlashCommand::Ide + | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Side | SlashCommand::Resume @@ -183,7 +182,6 @@ impl SlashCommand { | SlashCommand::Model | SlashCommand::Fast | SlashCommand::Personality - | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::Keymap | SlashCommand::Vim @@ -273,10 +271,10 @@ mod tests { } #[test] - fn auto_review_command_is_autoreview() { - assert_eq!(SlashCommand::AutoReview.command(), "autoreview"); + fn auto_review_command_is_approve() { + assert_eq!(SlashCommand::AutoReview.command(), "approve"); assert_eq!( - SlashCommand::from_str("autoreview"), + SlashCommand::from_str("approve"), Ok(SlashCommand::AutoReview) ); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap new file mode 100644 index 0000000000..912c0520cb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/keymap_setup.rs +expression: rendered +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Still waiting? If nothing changes when you press a key, your terminal is not sending that key to +Codex. Only received keys can be assigned as shortcuts. + +Waiting for a keypress... diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap new file mode 100644 index 0000000000..7126a40f94 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_debug(&view, 80)" +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Tip: Codex can only inspect keys your terminal sends. + +Waiting for a keypress... diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap new file mode 100644 index 0000000000..95c9a47866 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/keymap_setup.rs +expression: rendered +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Tip: Codex can only inspect keys your terminal sends. + +Detected: ctrl + o +Config key: ctrl-o +Raw event: code=Char('o'), modifiers=ctrl, kind=Press + +Assigned actions: + - global.copy (Copy) - Copy the last agent response to the clipboard. [Default] 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 a9e6b02e80..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 + [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 4c5cf695b4..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,16 +2,17 @@ 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) +tab: Debug (1 selectable) Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default 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 76a0460868..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,10 +5,10 @@ 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 - Navigation Approval + [All] Common Customized (0) 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_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 674b2caf6c..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 + [All] Common Customized (0) 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/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, + }) + }) + } +} 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); diff --git a/defs.bzl b/defs.bzl index 043dec3c36..fc8b0e0da6 100644 --- a/defs.bzl +++ b/defs.bzl @@ -243,10 +243,12 @@ def codex_rust_crate( targets generated from `tests/*.rs`. test_data_extra: Extra runtime data for tests. test_shard_counts: Mapping from generated test target name to Bazel - shard count. Matching tests use native Bazel sharding on the - original test label, while rules_rust assigns each Rust test case - to a stable bucket by hashing the test name. Matching tests are - also marked flaky, which gives them Bazel's default three attempts. + shard count. Matching tests use native Bazel sharding on the outer + workspace-root launcher, not rules_rust's inner sharding wrapper. + The launcher resolves the real Rust test binary through runfiles + and then assigns each libtest case to a stable bucket by hashing + the test name. Matching tests are also marked flaky, which gives + them Bazel's default three attempts. test_tags: Tags applied to unit + integration test targets. Typically used to disable the sandbox, but see https://bazel.build/reference/be/common-definitions#common.tags unit_test_timeout: Optional Bazel timeout for the unit-test target @@ -408,34 +410,91 @@ def codex_rust_crate( test_kwargs.update(integration_test_kwargs) test_shard_count = _test_shard_count(test_shard_counts, test_name) if test_shard_count: - test_kwargs["experimental_enable_sharding"] = True + # Put Bazel sharding on the label users/CI invoke. Do not set + # rules_rust's experimental_enable_sharding on the Rust test + # binary: that creates an intermediate wrapper that expects a + # symlink runfiles tree, while this repo intentionally runs with + # --noenable_runfiles and usually has only a runfiles manifest. test_kwargs["shard_count"] = test_shard_count test_kwargs["flaky"] = True - # Keep the existing integration test shape on non-gnullvm platforms. - # Windows cross tests need workspace_root_test so runfile env vars - # resolve to Windows-native absolute paths before the test starts. - rust_test( - name = test_name, - crate_name = test_crate_name, - crate_root = test, - srcs = [test], - data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra, - compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra, - deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra, - # Bazel has emitted both `codex-rs//...` and - # `../codex-rs//...` paths for `file!()`. Strip either - # prefix so Insta records Cargo-like metadata such as `core/tests/...`. - rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS + [ - "--remap-path-prefix=../codex-rs=", - "--remap-path-prefix=codex-rs=", - ], - rustc_env = rustc_env, - env = cargo_env, - target_compatible_with = WINDOWS_GNULLVM_INCOMPATIBLE, - tags = test_tags, - **test_kwargs - ) + integration_test_binary = test_name + "-bin" + + # There are three generated integration-test shapes: + # + # 1. Unsharded native tests keep the plain rust_test label for minimal + # churn and the usual rules_rust Cargo-like environment. + # 2. Sharded native tests split into a manual rust_test binary plus an + # outer workspace_root_test. The outer test action receives Bazel's + # sharding environment, resolves the real binary through the + # runfiles manifest, and implements stable libtest sharding itself. + # 3. Windows cross tests always use the workspace_root_test wrapper so + # runfile env vars become Windows-native absolute paths before the + # Rust process starts. + if test_shard_count: + # This target is intentionally a binary-like helper, not the public + # test target. The wrapper below owns cwd setup, runfile env + # materialization, sharding, and flaky retry behavior. + rust_test( + name = integration_test_binary, + crate_name = test_crate_name, + crate_root = test, + srcs = [test], + data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra, + compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra, + deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra, + # Bazel has emitted both `codex-rs//...` and + # `../codex-rs//...` paths for `file!()`. Strip either + # prefix so Insta records Cargo-like metadata such as `core/tests/...`. + rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS + [ + "--remap-path-prefix=../codex-rs=", + "--remap-path-prefix=codex-rs=", + ], + rustc_env = rustc_env, + target_compatible_with = WINDOWS_GNULLVM_INCOMPATIBLE, + tags = test_tags + ["manual"], + ) + + workspace_root_test( + name = test_name, + env = test_env, + # CARGO_BIN_EXE_* values are rlocation paths at analysis time. + # The launcher rewrites them to absolute paths at execution + # time so tests keep working after chdir_workspace_root and on + # manifest-only platforms. + runfile_env = cargo_env_runfiles, + test_bin = ":" + integration_test_binary, + workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker", + target_compatible_with = WINDOWS_GNULLVM_INCOMPATIBLE, + tags = test_tags, + **test_kwargs + ) + else: + # For unsharded tests, the direct rust_test rule is still fine: + # there is no rules_rust sharding wrapper to bypass, and env can + # use rlocation paths directly because the test starts under + # Bazel's normal test environment. + rust_test( + name = test_name, + crate_name = test_crate_name, + crate_root = test, + srcs = [test], + data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra, + compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra, + deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra, + # Bazel has emitted both `codex-rs//...` and + # `../codex-rs//...` paths for `file!()`. Strip either + # prefix so Insta records Cargo-like metadata such as `core/tests/...`. + rustc_flags = rustc_flags_extra + WINDOWS_RUSTC_LINK_FLAGS + [ + "--remap-path-prefix=../codex-rs=", + "--remap-path-prefix=codex-rs=", + ], + rustc_env = rustc_env, + env = cargo_env, + target_compatible_with = WINDOWS_GNULLVM_INCOMPATIBLE, + tags = test_tags, + **test_kwargs + ) windows_cross_test_kwargs = {} windows_cross_test_kwargs.update(integration_test_kwargs) diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index 8cfa60ba19..b76fc2a830 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -38,7 +38,17 @@ else )" fi if [[ "${RUNNER_OS:-}" != "Windows" ]]; then + # Non-Windows clippy jobs lint the native test binaries; the + # Windows-cross binaries exist only for the fast Windows test leg. manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -- '-windows-cross-bin$' || true)" +elif [[ $windows_cross_compile -eq 1 ]]; then + # `bazel query` is intentionally pre-analysis and does not remove targets + # made incompatible by `target_compatible_with`. Sharded integration tests + # add native-only manual helpers such as `core-all-test-bin`, plus separate + # `core-all-test-windows-cross-bin` helpers for the Windows cross leg. Keep + # the Windows helpers and unit-test helpers, but do not pass the native-only + # sharded integration helpers as explicit clippy targets. + manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -- '-test-bin$' || true)" fi printf '%s\n' \